#!/System/Library/Frameworks/Ruby.framework/Versions/Current/usr/bin/ruby
# == Synopsis
#
# gen_build: create build.ninja based on target files
#
# == Usage
#
# --help:
#    show help.
#
# --output/-o «file»:
#    the «file» to write build info into.
#
# --build-directory/-C «directory»:
#    where build files should go.
#
# --define/-d «name»=«value»:
#    define a variable that ends up in build.ninja.
require 'optparse'
require 'shellwords'
require 'set'

BIN = File.dirname(__FILE__)

class Context
  def initialize(options = { }, dir = nil, parent = nil)
    @options, @dir, @parent = options, dir, parent
  end

  def derive(options, dir = nil)
    Context.new(options, dir, self)
  end

  def to_h
    chain.inject(Hash.new) do |hash, obj|
      obj.options.each do |key, value|
        hash[key] = hash.key?(key) ? value + "\n" + hash[key] : value
      end
      hash
    end
  end

  def keys
    chain.flat_map { |obj| obj.options.keys }.uniq
  end

  def [](key)
    res = chain.map { |obj| obj.options[key] }.reject { |value| value.nil? }
    res.empty? ? nil : res.join("\n")
  end

  def array_for(key)
    chain.flat_map do |obj|
      value = obj.options[key]
      value.is_a?(Array) ? value : Shellwords.shellwords(value.to_s)
    end
  end

  def path_for(key)
    if instance = chain.find { |obj| obj.options.key?(key) }
      instance.dir ? File.join(instance.dir, instance.options[key]) : instance.options[key]
    end
  end

  def paths_for(key)
    chain.select { |obj| obj.options.key?(key) }.flat_map do |instance|
      files = Dir.chdir(instance.dir || '.') do
        globs = instance.array_for(key).reject { |glob| glob =~ /^@(\w+)/ }
        globs.flat_map { |glob| Dir.glob(glob) }
      end
      files.map { |file| instance.dir ? File.join(instance.dir, file) : file }
    end
  end

  def target_names_for(key)
    self.array_for(key).map { |name| name =~ /^@(\w+)/ ? $1 : nil }.reject { |name| name.nil? }
  end

  protected

  def chain
    res = [ self ]
    res << res.last.parent until res.last.parent.nil?
    res
  end

  attr_reader :options, :dir, :parent
end

class Asset
  attr_accessor :file, :context

  def initialize(file, context = Context.new)
    @file, @context = file, context
  end
end

class Target
  attr_reader :dir, :context

  def Target.read(file)
    res = { }
    File.read(file).scan(/^([\w\/]+)\s*(\+=|-=|=)[ \t]*(.*)$/).each do |key, op, value|
      if op == '+=' && res.key?(key)
        res[key] << ' ' << value
      else
        res[key] = value
      end
    end
    res
  end

  def initialize(dir, context)
    @dir, @context = dir, context
    @files = [ ]
  end

  def setup_build(buildfile, all_targets)
    abort "*** setup_build already called" unless @files.empty?

    buildfile.assign("#{self.name}_dir", File.join(buildfile.dir, self.dir))

    targets = [ self.name, *link_dependencies(all_targets) ].map { |name| all_targets[name] }
    objects = targets.flat_map { |target| target.objects(buildfile, all_targets) }
    tests   = targets.flat_map { |target| target.test_results(buildfile, all_targets) }

    sign_flags, sign_deps = [], []
    if entitlements = @context.path_for('CS_ENTITLEMENTS')
      sign_flags << "--entitlements #{entitlements.shellescape}"
      sign_deps << entitlements
    end
    if codesign_flags = @context['CODESIGN_FLAGS']
      sign_flags << codesign_flags
    end

    is_bundle = targets.any? { |target| target.context.keys.any? { |key| key =~ /^CP_/ } }

    if is_bundle
      bundle      = "#{self.name}.#{@context['BUNDLE_EXTENSION'] || 'app'}"
      bundle_path = File.join(buildfile.dir, self.dir, bundle)

      resources = [ ]

      targets.each do |target|
        target.resources(buildfile, all_targets).each do |file, path, deps|
          # Do not copy the main Info.plist from other targets
          next if path == 'Contents/Info.plist' && target != self
          resources << buildfile.build('copy', file, File.join(buildfile.dir, self.dir, bundle, path), nil, deps)
          @files << [ file, File.join(bundle, path), deps ]
        end
      end

      executable = "#{bundle_path}/Contents/MacOS/#{self.name}"
      signature  = "#{bundle_path}/Contents/_CodeSignature/CodeResources"

      buildfile.build('link', objects, executable, link_flags(all_targets))
      buildfile.meta(bundle_path, executable, *resources)
      buildfile.build('sign', bundle_path, signature, sign_flags, sign_deps)
      buildfile.meta(self.name, signature)

      @files << [ executable, "#{bundle}/Contents/MacOS/#{self.name}", signature ]
      @files << [ signature, "#{bundle}/Contents/_CodeSignature/CodeResources" ]

      if File.extname(bundle) == '.app'
        buildfile.build('run_application', bundle_path, "#{bundle_path}.run", nil, signature)
        buildfile.meta("#{self.name}/run", "#{bundle_path}.run")
        buildfile.assign("#{self.name}_app", bundle_path)
        buildfile.assign("#{self.name}_sign", signature)
      end
    else
      signature  = File.join(buildfile.dir, self.dir, "#{self.name}.sign")
      executable = File.join(buildfile.dir, self.dir, self.name)

      buildfile.build('link', objects, executable, link_flags(all_targets))
      buildfile.build('sign', executable, signature, sign_flags, sign_deps)
      buildfile.build('run', executable, "#{executable}.run", nil, signature)
      buildfile.meta(self.name, signature)
      buildfile.meta("#{self.name}/run", "#{executable}.run")

      buildfile.assign("#{self.name}_exe", executable)
      buildfile.assign("#{self.name}_sign", signature)

      @files << [ executable, self.name, signature ]
    end

    buildfile.meta("#{self.name}/tests", *tests)
  end

  protected

  attr_reader :files

  def name
    @context['TARGET_NAME']
  end

  def link_dependencies(all_targets)
    if @link_dependencies.nil?
      queue = [ self.name ]
      seen  = Set.new(queue)
      while name = queue.shift
        new_names  = Set.new(all_targets[name].context.array_for('LINK'))
        queue     += (new_names - seen).to_a
        seen      += new_names
      end
      @link_dependencies = seen.delete(self.name).to_a.sort
    end
    @link_dependencies
  end

  def include_directory(buildfile)
    if @include_directory.nil?
      @include_directory = File.join(buildfile.dir, self.dir, 'include')

      headers = @context.paths_for('EXPORT').map do |file|
        buildfile.build('copy', file, File.join(@include_directory, self.name, File.basename(file)))
      end

      buildfile.meta(@include_directory, *headers)
    end
    @include_directory
  end

  def objects_for_sources(buildfile, all_targets, sources, include_dirs: nil)
    dirs = (link_dependencies(all_targets) + @context.array_for('IMPORT')).uniq.map do |name|
      all_targets[name].include_directory(buildfile)
    end

    dirs += include_dirs unless include_dirs.nil?

    context = @context.derive({ 'include_dirs' => dirs })

    sources.flat_map do |file|
      buildfile.transform(Asset.new(file, context)).map do |asset|
        abort "*** No rule to make object file from: #{file}" unless File.extname(asset.file) == '.o'
        asset.file
      end
    end
  end

  def objects(buildfile, all_targets)
    @objects ||= objects_for_sources(buildfile, all_targets, @context.paths_for('SOURCES'))
  end

  def resources(buildfile, all_targets)
    if @resources.nil?
      @resources = [ ]

      @context.keys.each do |key|
        if key =~ /^CP_(.+)/
          asset_section = "Contents/#$1"
          queue = @context.paths_for(key).map { |file| [ file, asset_section ] }

          while info = queue.shift do
            file, dest = *info

            # Any Info.plist destined for Resources will instead be used as the bundle’s main Info.plist
            dest = 'Contents' if File.basename(file) == 'Info.plist' && dest == 'Contents/Resources'

            buildfile.transform(Asset.new(file, @context)).each do |new_asset|
              if File.directory?(new_asset.file)
                queue += Dir.entries(new_asset.file).reject { |path| path =~ /^\./ }.map do |entry|
                  [ File.join(new_asset.file, entry), File.join(dest, File.basename(file)) ]
                end
              else
                @resources << [ new_asset.file, File.join(dest, File.basename(new_asset.file)) ]
              end
            end
          end

          @context.target_names_for(key).each do |name|
            all_targets[name].files.each do |src, dst, deps|
              @resources << [ src, File.join(asset_section, dst), deps ]
            end
          end
        end
      end
    end
    @resources
  end

  def link_flags(all_targets)
    targets    = [ self.name, *link_dependencies(all_targets) ].map { |name| all_targets[name] }

    mixed_libs = targets.inject(Set.new) { |set, target| set += target.context.array_for('LIBS') }
    frameworks = targets.inject(Set.new) { |set, target| set += target.context.array_for('FRAMEWORKS') }
    ln_flags   = targets.inject(Set.new) { |set, target| set += target.context.array_for('LN_FLAGS') }

    static, dynamic = mixed_libs.partition { |lib| lib =~ /\.a$/ }

    hash = { '' => [ *ln_flags, *static ], '-l' => dynamic, '-framework ' => frameworks }
    hash.flat_map do |prefix, args|
      args.map { |arg| prefix + arg.sub(/^(?=.*?\$).*$/, '"\&"') }
    end
  end

  def test_results(buildfile, all_targets)
    if @test_results.nil?
      @test_results = []

      cxx_test_sources = @context.paths_for('TESTS')
      unless cxx_test_sources.empty?
        ext = cxx_test_sources.any? { |file| File.extname(file) == '.mm' } ? 'mm' : 'cc'

        test_source        = File.join(buildfile.dir, self.dir, "test_#{self.name}.#{ext}")
        test_executable    = File.join(buildfile.dir, self.dir, "test_#{self.name}")
        test_passed        = File.join(buildfile.dir, self.dir, "test_#{self.name}.passed")
        test_ignore_passed = File.join(buildfile.dir, self.dir, "test_#{self.name}.ignore")

        targets = [ self.name, *link_dependencies(all_targets) ].map { |name| all_targets[name] }
        test_objects  = targets.inject(Array.new) { |arr, target| arr += target.objects(buildfile, all_targets) }
        test_objects += objects_for_sources(buildfile, all_targets, [ test_source ], include_dirs: [ self.include_directory(buildfile) ])

        buildfile.build('gen_test', cxx_test_sources, test_source)
        buildfile.build('link', test_objects, test_executable, link_flags(all_targets))
        buildfile.build('run_test', test_executable, test_passed)
        buildfile.build('always_run_test', test_executable, test_ignore_passed)

        buildfile.meta("#{self.name}/test", test_ignore_passed)
        @test_results << test_passed
      end

      objc_test_sources = @context.paths_for('TEST_SOURCES')
      unless objc_test_sources.empty?
        ext = objc_test_sources.any? { |file| File.extname(file) == '.mm' } ? 'mm' : 'cc'

        test_source        = File.join(buildfile.dir, self.dir, "cxx_test_#{self.name}.#{ext}")
        test_executable    = File.join(buildfile.dir, self.dir, "cxx_test_#{self.name}")
        test_passed        = File.join(buildfile.dir, self.dir, "cxx_test_#{self.name}.passed")
        test_ignore_passed = File.join(buildfile.dir, self.dir, "cxx_test_#{self.name}.ignore")

        targets = [ self.name, *link_dependencies(all_targets) ].map { |name| all_targets[name] }
        test_objects  = targets.inject(Array.new) { |arr, target| arr += target.objects(buildfile, all_targets) }
        test_objects += objects_for_sources(buildfile, all_targets, [ test_source ], include_dirs: [ "#{BIN}/CxxTest", self.include_directory(buildfile) ])

        buildfile.build('gen_cxx_test', objc_test_sources, test_source)
        buildfile.build('link', test_objects, test_executable, link_flags(all_targets))
        buildfile.build('run_test', test_executable, test_passed)
        buildfile.build('always_run_test', test_executable, test_ignore_passed)

        buildfile.meta("#{self.name}/cxx_test", test_ignore_passed)
        @test_results << test_passed
      end
    end
    @test_results
  end
end

class Buildfile
  attr_reader :dir

  def initialize(context, dir, variables = {})
    @context, @dir, @variables = context, dir, variables
    @rules, @build, @default_targets = [], [], []
  end

  def add_basic_rules
    self.rule('copy', {
      'command'     => '/bin/cp -Xp $in $out && touch $out',
      'description' => 'Copy ‘$in’…',
    })

    self.rule('gen_test', {
      'command'     => "#{BIN}/gen_test > $out~ $in && mv $out~ $out",
      'description' => 'Generate test ‘$out’…',
    })

    self.rule('gen_cxx_test', {
      'command'     => "#{BIN}/CxxTest/bin/cxxtestgen --have-std -o $out --runner=unix $in",
      'description' => 'Generate test ‘$out’…',
    })

    self.rule('run_test', {
      'command'     => '$in $test_runner_flags && touch $out',
      'description' => 'Run ‘$in’…',
    })

    self.rule('always_run_test', {
      'command'     => '$in $test_runner_flags',
      'description' => 'Run ‘$in’…',
    })

    self.rule('link', {
      'command'     => 'xcrun clang++ $args -o $out $in',
      'description' => 'Link executable ‘$out’…',
    })

    self.rule('sign', {
      'command'     => 'xcrun codesign $args -fs "$identity" $in && touch $out',
      'description' => 'Sign ‘$in’…',
    })

    self.rule('index_help', {
      'command'     => '/usr/bin/hiutil -Cvaf $out "$HELP_BOOK"',
      'description' => 'Index help book ‘$HELP_BOOK’…',
    })

    self.rule('run', {
      'command'     => '$in',
      'description' => 'Run ‘$in’…',
      'pool'        => 'console',
    })

    self.rule('run_application', {
      'command' => <<~SHELL.gsub(/\s+/, ' '),
        {
          app_name=$$(basename $in .app);
          if pgrep "$$app_name"; then
            if [[ -x "$$DIALOG" && $$("$$DIALOG" alert --title "Relaunch $$app_name?" --body "Would you like to quit $$app_name and start the newly built version?" --button1 Relaunch --button2 Cancel|pl) != *"buttonClicked = 0"* ]];
              then exit;
            fi;
            pkill "$$app_name";
            while pgrep "$$app_name"; do
              if (( ++n == 10 )); then
                test -x "$$DIALOG" && "$$DIALOG" alert --title "Relaunch Timed Out" --body "Unable to exit $$app_name." --button1 OK;
                exit;
              fi;
              sleep .2;
            done;
          fi;
          open $in --args -disableSessionRestore NO;
        } </dev/null &>/dev/null &
SHELL
      'description' => 'Run ‘$in’…',
    })
  end

  def default(*names)
    @default_targets += names
  end

  def rule(name, values)
    @rules << [ name, values ]
  end

  def build(rule, src, dst, args = nil, deps = nil, outs = nil, order_only = nil)
    @build << [ rule, src, dst, args, deps, outs, order_only ]
    dst
  end

  def meta(name, *deps)
    build('phony', deps, name)
  end

  def transform(initial_asset)
    derived_assets = [ ]

    queue = [ initial_asset ]
    while asset = queue.shift
      if compiler = compiler_for(asset.file)
        if compiler.filter?
          derived_assets += compiler.transform(asset, self)
        else
          queue += compiler.transform(asset, self)
        end
      else
        derived_assets << asset
      end
    end

    derived_assets
  end

  def assign(key, value)
    @variables[key] = value
  end

  def to_s
    variables = @context ? @variables.merge(@context.to_h) : @variables

    width = variables.map { |key, value| key.length }.max { |lhs, rhs| lhs <=> rhs }
    res   = variables.map { |key, value| format("%-#{width}s = %s\n", key, value) }.join
    res << "\n" unless variables.empty?

    @rules.map do |name, values|
      flags = values.map { |k, v| "  #{k} = #{v}" }.join("\n")
      res << "rule #{name}\n#{flags}\n\n"
    end

    esc = lambda { |x| Array(x).map { |file| file.gsub(/[ :$]/, '$\&') }.join(' ') }
    @build.each do |rule, src, dst, args, deps, outs, order_only|
      res << "build #{esc.call(dst)}"
      res << " | #{esc.call(outs)}" unless outs.to_a.empty?
      res << ": #{rule} #{esc.call(src)}"
      res << " | #{esc.call(deps)}" unless deps.to_s.empty?
      res << " || #{esc.call(order_only)}" unless order_only.to_a.empty?
      res << "\n"
      args = Array(args).join("\n").gsub(/\s+/, ' ').strip
      res << "  args = #{args}\n" unless args.empty?
      res << "\n"
    end

    unless @default_targets.empty?
      res << "default #{@default_targets.join(' ')}\n\n"
    end

    res
  end

  private

  def compiler_for(file)
    Plugin.plugins_of_type(Compiler).each do |klass|
      (klass.transforms || {}).each do |ext, dest_ext|
        if file =~ /\b#{Regexp.escape ext}$/
          return klass.new(self, @context, ext, ext, dest_ext)
        end
      end

      (klass.extensions || {}).each do |canonical_ext, extensions|
        extensions.each do |ext|
          if file =~ /\b#{Regexp.escape ext}$/
            return klass.new(self, @context, ext, canonical_ext, klass.transforms[canonical_ext])
          end
        end
      end
    end
    nil
  end
end

class CleanupTargets
  def initialize(ninja_file, builddir)
    @ninja_file, @builddir = ninja_file, builddir
    @old_targets = self.targets(ninja_file, builddir)
  end

  def targets(ninja_file, builddir)
    return nil if ninja_file == '/dev/stdout' || !File.exists?(ninja_file)

    targets = %x{ ${TM_NINJA:-ninja} -C #{File.dirname(ninja_file).shellescape} -f #{ninja_file.shellescape} -t targets all }
    return nil if $? != 0

    res = Set.new
    targets.each_line do |line|
      res << $& if line =~ /^#{Regexp.escape(builddir)}.*(?=:(?! (phony|(tbz|zip)_archive)))/
    end
    res
  end

  def run(dry_run: false)
    new_targets = self.targets(@ninja_file, @builddir)

    if @old_targets && new_targets
      targets_lost = @old_targets - new_targets
      targets_lost.each do |path|
        if File.exists?(path)
          STDERR << "Remove old target ‘#{path.sub(/#{Regexp.escape(@builddir)}/, '$builddir')}’…\n"
          # FIXME Does not seem to work with symbolic links (Proxy.png → Settings.png)
          File.unlink(path) unless dry_run == true
        end
      end
    end
  end
end

class Plugin
  @@plugins = []

  def self.inherited(subclass)
    @@plugins << subclass
  end

  def self.plugins_of_type(klass)
    @@plugins.select { |candidate| candidate < klass }
  end
end

class Compiler < Plugin
  class << self
    def transforms(hash = nil)
      @transforms = hash || @transforms
    end

    def extensions(hash = nil)
      @extensions = hash || @extensions
    end
  end

  @@did_setup = []

  def initialize(buildfile, context, ext, canonical_ext, dest_ext)
    @ext, @canonical_ext, @dest_ext = ext, canonical_ext, dest_ext
    unless @@did_setup.include?(self.class)
      @@did_setup << self.class
      self.setup(buildfile, context)
    end
  end

  def filter?
    @canonical_ext == @dest_ext
  end

  def derive_asset(asset, dest, options = nil)
    Asset.new(File.expand_path(asset.file.chomp(@ext) + @dest_ext, dest), options ? asset.context.derive(options) : asset.context)
  end
end

class CompileRagel < Compiler
  transforms '.rl' => '.cc'

  def setup(buildfile, context)
    buildfile.rule('gen_ragel', {
      'command'     => 'ragel -o $out $in',
      'description' => 'Generate source from ‘$in’…',
    })
  end

  def transform(asset, buildfile)
    config = { 'FLAGS' => Shellwords.shelljoin([ '-iquote', File.dirname(asset.file) ]) }
    new_asset = self.derive_asset(asset, buildfile.dir, config)
    buildfile.build('gen_ragel', asset.file, new_asset.file)
    [ new_asset ]
  end
end

class CompileCapnp < Compiler
  transforms '.capnp' => '.capnp.c++'

  def setup(buildfile, context)
    buildfile.rule('gen_capnp', {
      'command'     => 'PATH="$capnp_prefix/bin:$$PATH" capnp compile -oc++ $in',
      'description' => 'Generate source from ‘$in’…',
    })
  end

  def transform(asset, buildfile)
    new_asset_source = Asset.new(asset.file.chomp(@ext) + '.capnp.c++', asset.context)
    new_asset_header = Asset.new(asset.file.chomp(@ext) + '.capnp.h', asset.context)
    buildfile.build('gen_capnp', asset.file, [ new_asset_source.file, new_asset_header.file ])
    [ new_asset_source ]
  end
end

class CompileClang < Compiler
  transforms '.c' => '.o', '.m' => '.o', '.cc' => '.o', '.mm' => '.o'
  extensions '.cc' => [ '.c++', '.cxx', '.cpp' ]

  COMPILER_INFO = {
    '.c'  => { :rule => 'build_c',  :compiler => 'xcrun clang',   :flags => 'C_FLAGS',      :pch => '-x c-header'             },
    '.m'  => { :rule => 'build_m',  :compiler => 'xcrun clang',   :flags => 'OBJC_FLAGS',   :pch => '-x objective-c-header'   },
    '.cc' => { :rule => 'build_cc', :compiler => 'xcrun clang++', :flags => 'CXX_FLAGS',    :pch => '-x c++-header'           },
    '.mm' => { :rule => 'build_mm', :compiler => 'xcrun clang++', :flags => 'OBJCXX_FLAGS', :pch => '-x objective-c++-header' },
  }

  @@prelude = { }

  def setup(buildfile, context)
    COMPILER_INFO.each do |_, info|
      buildfile.rule(info[:rule], {
        'command'     => "#{info[:compiler]} $args -o $out -MMD -MF $out.d $in",
        'depfile'     => "$out.d",
        'deps'        => "gcc",
        'description' => "Compile ‘$in’…",
      })
    end

    context.paths_for('PRELUDE').each do |file|
      ext = File.extname(file)
      if info = COMPILER_INFO[ext]
        flags = [ info[:pch] ]
        flags << context[info[:flags]]
        flags << context['FLAGS']

        outfile = "#{File.expand_path(file, buildfile.dir)}.gch"
        buildfile.build(info[:rule], file, outfile, flags)
        @@prelude[ext] = { :outfile => outfile, :include => outfile.chomp('.gch') }
      end
    end
  end

  def transform(asset, buildfile)
    info = COMPILER_INFO[@canonical_ext]

    flags = [ "-include #{@@prelude[@canonical_ext][:include]}" ]
    deps  = [ @@prelude[@canonical_ext][:outfile] ]

    order_only = nil

    if dirs = asset.context.array_for('include_dirs')
      flags << dirs.map { |dir| "-I#{dir}" }.shelljoin
      order_only = dirs
    end

    flags << asset.context[info[:flags]]
    flags << asset.context['FLAGS']

    new_asset = self.derive_asset(asset, buildfile.dir)
    buildfile.build(info[:rule], asset.file, new_asset.file, flags, deps, nil, order_only)
    [ new_asset ]
  end
end

class CompileXib < Compiler
  transforms '.xib' => '.nib'

  def setup(buildfile, context)
    buildfile.rule('compile_xib', {
      'command'     => 'xcrun ibtool --errors --warnings --notices --output-format human-readable-text --minimum-deployment-target $APP_MIN_OS --compile $out $in',
      'description' => 'Compile xib ‘$in’…',
    })
  end

  def transform(asset, buildfile)
    new_asset = self.derive_asset(asset, buildfile.dir)
    buildfile.build('compile_xib', asset.file, new_asset.file)
    [ new_asset ]
  end
end

class CompileXCAssets < Compiler
  transforms '.xcassets' => '.car'

  def setup(buildfile, context)
    buildfile.rule('xcassets', {
      'command'     => 'xcrun actool --errors --warnings --notices --output-format human-readable-text --platform macosx --minimum-deployment-target $APP_MIN_OS --compile "$$(dirname $out)" $in',
      'description' => 'Compile xcassets ‘$in’…',
    })
  end

  def transform(asset, buildfile)
    deps = Dir.glob("#{asset.file}/**/*")

    new_asset = Asset.new(File.join(buildfile.dir, File.dirname(asset.file), 'Assets.car'))
    buildfile.build('xcassets', asset.file, new_asset.file, nil, deps)
    [ new_asset ]
  end
end

class CompileStrings < Compiler
  transforms '.strings' => '.strings'

  def setup(buildfile, context)
    buildfile.rule('copy_as_utf16', {
      'command'     => 'if [[ $$(head -c2 $in) == $$\'\\xFF\\xFE\' || $$(head -c2 $in) == $$\'\\xFE\\xFF\' ]]; then /bin/cp -XRp $in $out; else iconv -f utf-8 -t utf-16 < $in > $out~ && mv $out~ $out; fi',
      'description' => 'Copy ‘$in’ as UTF-16…',
    })
  end

  def transform(asset, buildfile)
    new_asset = self.derive_asset(asset, buildfile.dir)
    buildfile.build('copy_as_utf16', asset.file, new_asset.file)
    [ new_asset ]
  end
end

class CompilePropertyList < Compiler
  transforms 'Info.plist' => 'Info.plist'

  def setup(buildfile, context)
    buildfile.rule('process_plist', {
      'command'     => "#{BIN}/process_plist > $out~ $args $in && mv $out~ $out",
      'description' => 'Process plist ‘$in’…',
    })
  end

  def transform(asset, buildfile)
    flags = [ "-dTARGET_NAME='#{asset.context['TARGET_NAME']}'" ]
    flags << asset.context['PLIST_FLAGS']

    new_asset = self.derive_asset(asset, buildfile.dir)
    buildfile.build('process_plist', asset.file, new_asset.file, flags, [ "#{BIN}/process_plist" ])
    [ new_asset ]
  end
end

class CompileMarkdown < Compiler
  transforms '.md' => '.html'
  extensions '.md' => [ '.mdown' ]

  def setup(buildfile, context)
    buildfile.rule('markdown', {
      'command'     => "#{BIN}/gen_html > $out~ $args $in && mv $out~ $out",
      'description' => 'Generate ‘$out’…',
    })
  end

  def transform(asset, buildfile)
    header = asset.context.path_for('HTML_HEADER')
    footer = asset.context.path_for('HTML_FOOTER')

    deps = [ "#{BIN}/gen_html" ]
    deps << header unless header.nil?
    deps << footer unless footer.nil?

    flags = []
    flags << "-h #{header.shellescape}" unless header.nil?
    flags << "-f #{footer.shellescape}" unless footer.nil?

    md_header = asset.context.path_for('MARKDOWN_HEADER')
    md_footer = asset.context.path_for('MARKDOWN_FOOTER')
    infiles = [ md_header, asset.file, md_footer ].reject { |file| file.nil? }

    new_asset = self.derive_asset(asset, buildfile.dir)
    buildfile.build('markdown', infiles, new_asset.file, flags, deps)
    [ new_asset ]
  end
end

# ========
# = Main =
# ========

if __FILE__ == $PROGRAM_NAME
  outfile   = '/dev/stdout' 
  builddir  = File.expand_path('~/build')
  variables = { }

  OptionParser.new do |opts|
    opts.banner = "Usage: gen_build [options] "
    opts.separator "Synopsis"
    opts.separator "gen_build: create build.ninja based on target files"
    opts.separator "Options:"

    opts.on("-h", "--help", "show help.") do |v|
      puts opts
      exit
    end

    opts.on("-o", "--output FILE", "the «file» to write build info into.") do |v|
      outfile = v
    end

    opts.on("-C", "--build-directory DIRECTORY", "where build files should go.") do |v|
      builddir = v
    end

    opts.on("-d", "--define NAME=VALUE", "define a variable that ends up in build.ninja") do |v|
      variables[$1] = $2 if v =~ /^(\w+)\s*=\s*(.*)$/
    end
  end.parse!

  abort "No root target file specified" if ARGV.empty?
  ravefile = ARGV.shift

  Dir.chdir(File.dirname(ravefile)) do
    # ================
    # = Load Targets =
    # ================

    config = Context.new(Target.read(ravefile))
    files  = []

    all_targets = config.paths_for('TARGETS').map do |file|
      dir, options = File.dirname(file), Target.read(file)
      name, target = (options['TARGET_NAME'] ||= File.basename(dir)), Target.new(dir, config.derive(options, dir))
      files << [ file, target ]
      [ name, target ]
    end.to_h

    # ====================================
    # = Check Existence of Named Targets =
    # ====================================

    files.each do |file, target|
      target.context.array_for('LINK').each do |name|
        unless all_targets.key?(name =~ /^@(.*)/ ? $1 : name)
          abort "#{file}: no such target: #{name} (required by LINK)"
        end
      end

      target.context.keys.select { |key| key =~ /^CP_/ }.each do |key|
        target.context.array_for(key).each do |name|
          if name =~ /^@(.*)/ && !all_targets.key?($1)
            abort "#{file}: no such target: #{name} (required by #{key})"
          end
        end
      end
    end

    # =================================
    # = Topological Sort Root Targets =
    # =================================

    link_targets = Set.new(all_targets.flat_map { |_, target| target.context.array_for('LINK') })
    root_targets = all_targets.reject { |name, _| link_targets.include?(name) }

    nodes = root_targets.map do |name, target|
      keys = target.context.keys.select { |key| key =~ /^CP_/ }
      deps = keys.flat_map { |key| target.context.target_names_for(key) }
      [ name, deps ]
    end.to_h

    edge_count = nodes.map { |name, _| [ name, 0 ] }.to_h
    nodes.values.flatten.each { |target| edge_count[target] += 1 }

    ordered = [ ]

    until edge_count.empty?
      roots = edge_count.select { |_, deps| deps.zero? }
      abort "*** Fatal: Dependency cycle" if roots.empty?

      roots.each do |root, _|
        ordered << root
        edge_count.delete(root)
        nodes[root].each { |target| edge_count[target] -= 1 }
      end
    end

    # =====================
    # = Create build file =
    # =====================

    buildfile = Buildfile.new(config, builddir)
    buildfile.add_basic_rules

    ordered.reverse.each do |name|
      root_targets[name].setup_build(buildfile, all_targets)
    end

    # =====================================
    # = Calculate build file dependencies =
    # =====================================

    dependencies = all_targets.flat_map do |name, target|
      target.context.keys.select { |key| key =~ /^(SOURCES|TESTS|TEST_SOURCES|CP_.*)$/ }.flat_map do |key|
        all_targets[name].context.paths_for(key).map { |file| File.dirname(file) }
      end
    end.uniq
    dependencies += [ '.', ravefile, *config.paths_for('TARGETS') ]

    # ============================
    # = Write build file to disk =
    # ============================

    cleaner = CleanupTargets.new(outfile, builddir)

    File.write("#{builddir}/build.ninja", buildfile.to_s)
    File.write("#{builddir}/build.ninja.d", "build.ninja: #{dependencies.sort.map { |dep| dep.gsub(/ /, '\\ ') }.join(" \\\n  ")}\n")

    bootstrap = Buildfile.new(nil, builddir, variables)
    bootstrap.assign('ninja_required_version', '1.5')
    bootstrap.assign('builddir', builddir)

    strip_variables = Set.new(config.keys) << 'ninja_required_version' << 'builddir'
    args = variables.reject { |key, value| strip_variables.include?(key) }
    args = args.map { |key, value| "-d'#{key}=#{value =~ /\$/ ? value.gsub(/\$/, '$$') :  "$#{key}"}'" }.join(' ')

    bootstrap.rule('gen_build', {
      'command'     => "#{__FILE__} -C \"$builddir\" #{args} -o $out $in",
      'depfile'     => '$builddir/$out.d',
      'generator'   => 'true',
      'description' => 'Generate ‘$out’…',
    })

    bootstrap.build('gen_build', 'target', 'build.ninja', nil, __FILE__)

    open("#{outfile}", "w") do |io|
      userfile = "#{ENV['USER']}.ninja"
      io << bootstrap.to_s
      io << "include $builddir/build.ninja\n"
      io << "include #{userfile}\n" if userfile != 'build.ninja' && File.exists?(File.join(File.dirname(outfile), userfile))
    end

    cleaner.run(dry_run: false)
  end
end
