# frozen_string_literal: true

require 'rake/clean'

require_relative '../../lib/sass/elf'

ELF = Sass.const_get(:ELF)

task default: %i[install clean]

task install: %w[cli.rb] do
  Rake::Task['embedded_sass_pb.rb'].invoke unless File.exist?('embedded_sass_pb.rb')
end

CLEAN.include %w[
  protoc.exe
  ruby
  true
  *.proto
  *.tar.gz
  *.zip
]

CLOBBER.include %w[
  dart-sass
  cli.rb
  embedded_sass_pb.rb
  node_modules
  bun.lockb
  package-lock.json
  pnpm-lock.yaml
  yarn.lock
]

file 'protoc.exe' do |t|
  fetch(SassConfig.protoc, t.name)
  chmod 'a+x', t.name
rescue NotImplementedError
  File.write(t.name, <<~PROTOC_EXE)
    #!#{RbConfig.ruby}
    # frozen_string_literal: true
    Kernel.exec('protoc', *ARGV)
  PROTOC_EXE
  chmod 'a+x', t.name
end

file 'dart-sass/sass' do
  gem_install 'sass-embedded', SassConfig.gem_version, SassConfig.gem_platform do |installer|
    gh_attestation_verify(installer.gem, repo: 'sass-contrib/sass-embedded-host-ruby')
    mv File.absolute_path('ext/sass/dart-sass', installer.gem_dir), 'dart-sass'
  end
rescue StandardError
  archive = fetch(SassConfig.dart_sass)
  gh_attestation_verify(archive, repo: 'sass/dart-sass')
  unarchive archive
  rm archive
end

file 'node_modules/sass' do
  sh 'npm', 'install'
rescue StandardError
  begin
    sh 'yarn', 'install'
  rescue StandardError
    begin
      sh 'pnpm', 'install'
    rescue StandardError
      sh 'bun', 'install'
    end
  end
end

task 'dart-sass' do
  Rake::Task['dart-sass/sass'].invoke
rescue NotImplementedError
  Rake::Task['node_modules/sass'].invoke
end

file 'cli.rb' do |t|
  begin
    exe = '/usr/bin/sass'
    exe = "#{exe}#{['', '.bat', '.exe'].find { |ext| File.exist?("#{exe}#{ext}") }}"

    raise Errno::ENOENT, exe unless File.exist?(exe)

    runtime = 'dart-sass/src/dart'
    runtime = "#{runtime}#{['', '.exe'].find { |ext| File.exist?("#{runtime}#{ext}") }}"
    snapshot = 'dart-sass/src/sass.snapshot'

    command = if File.exist?(runtime) && File.exist?(snapshot)
                [runtime, snapshot]
              else
                [exe]
              end

    interpreter = File.open(command[0], 'rb') do |file|
      ELF.new(file).interpreter
    rescue ArgumentError
      nil
    end

    command_source = command.map do |argument|
      "File.absolute_path('#{argument}', __dir__).freeze"
    end.join(',
      ')
  rescue Errno::ENOENT
    package = 'node_modules/sass'

    script = File.join(package, SassConfig.package_json(package)['bin']['sass'])

    interpreter = nil

    command_source = [
      "'node'",
      "File.absolute_path('#{script}', __dir__).freeze"
    ].join(',
      ')
  end

  if interpreter.nil?
    File.write(t.name, <<~CLI_RB)
      # frozen_string_literal: true

      module Sass
        module CLI
          COMMAND = [
            #{command_source}
          ].freeze
        end

        private_constant :CLI
      end
    CLI_RB
  else
    File.write(t.name, <<~CLI_RB)
      # frozen_string_literal: true

      require_relative '../../lib/sass/elf'

      module Sass
        module CLI
          INTERPRETER = '#{interpreter}'

          INTERPRETER_SUFFIX = '/#{File.basename(interpreter)}'

          COMMAND = [
            *(ELF::INTERPRETER if ELF::INTERPRETER != INTERPRETER && ELF::INTERPRETER&.end_with?(INTERPRETER_SUFFIX)),
            #{command_source}
          ].freeze
        end

        private_constant :CLI
      end
    CLI_RB
  end
end

file 'embedded_sass.proto' => %w[cli.rb] do |t|
  fetch(SassConfig.embedded_sass_protocol, t.name)
end

rule '_pb.rb' => %w[.proto protoc.exe] do |t|
  sh './protoc.exe', '--proto_path=.', '--ruby_out=.', t.source
end

file 'true' do |t|
  case Platform::CPU
  when 'aarch64'
    ei_class  = ELF::ELFCLASS64
    ei_data   = ELF::ELFDATA2LSB
    e_machine = 0xb7
    e_flags   = 0

    # 0000000000400078 <PT_LOAD#0>:
    #   400078: d2800ba8     	mov	x8, #0x5d               // =93
    #   40007c: d2800000     	mov	x0, #0x0                // =0
    #   400080: d4000001     	svc	#0
    entry_point = [0xd2800ba8, 0xd2800000, 0xd4000001].pack('L<3')
  when 'arm'
    ei_class  = ELF::ELFCLASS32
    ei_data   = ELF::ELFDATA2LSB
    e_machine = 0x28
    e_flags   = 0x5000400

    # 00400054 <PT_LOAD#0>:
    #   400054: 2701         	movs	r7, #0x1
    #   400056: 2000         	movs	r0, #0x0
    #   400058: df00         	svc	#0x0
    entry_point = [0x2701, 0x2000, 0xdf00].pack('S<3')
  when 'riscv64'
    ei_class  = ELF::ELFCLASS64
    ei_data   = ELF::ELFDATA2LSB
    e_machine = 0xf3
    e_flags   = 0x5

    # 0000000000400078 <PT_LOAD#0>:
    #   400078: 05d00893     	li	a7, 0x5d
    #   40007c: 4501         	li	a0, 0x0
    #   40007e: 00000073     	ecall
    entry_point = [0x05d00893, 0x4501, 0x00000073].pack('L<S<L<')
  when 'x86_64'
    ei_class  = ELF::ELFCLASS64
    ei_data   = ELF::ELFDATA2LSB
    e_machine = 0x3e
    e_flags   = 0

    # 0000000000400078 <PT_LOAD#0>:
    #   400078: 31 ff                        	xorl	%edi, %edi
    #   40007a: b8 3c 00 00 00               	movl	$0x3c, %eax
    #   40007f: 0f 05                        	syscall
    entry_point = %w[31ffb83c0000000f05].pack('H*')
  else
    raise NotImplementedError
  end

  case ei_class
  when ELF::ELFCLASS32
    e_ehsize    = ELF::Elf32_Ehdr.sizeof
    e_phentsize = ELF::Elf32_Phdr.sizeof
    e_shentsize = ELF::Elf32_Shdr.sizeof
  when ELF::ELFCLASS64
    e_ehsize    = ELF::Elf64_Ehdr.sizeof
    e_phentsize = ELF::Elf64_Phdr.sizeof
    e_shentsize = ELF::Elf64_Shdr.sizeof
  else
    raise EncodingError
  end

  e_phoff  = e_ehsize

  p_offset = e_phoff + e_phentsize
  p_vaddr  = (2**22) + p_offset
  p_filesz = entry_point.length
  p_memsz  = p_filesz

  e_entry  = p_vaddr
  e_entry += 1 if Platform::CPU == 'arm'

  ELF.allocate.instance_eval do
    @ehdr = {
      e_ident: [127, 69, 76, 70, ei_class, ei_data, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0],
      e_type: ELF::ET_EXEC,
      e_machine:,
      e_version: 1,
      e_entry:,
      e_phoff:,
      e_shoff: 0,
      e_flags:,
      e_ehsize:,
      e_phentsize:,
      e_phnum: 1,
      e_shentsize:,
      e_shnum: 0,
      e_shstrndx: 0
    }
    @phdrs = [
      {
        p_type: ELF::PT_LOAD,
        p_flags: ELF::PF_R | ELF::PF_X,
        p_offset:,
        p_vaddr:,
        p_paddr: 0,
        p_filesz:,
        p_memsz:,
        p_align: 4096
      }
    ]
    @shdrs = []

    File.open(t.name, 'wb', 0o755) do |file|
      dump(file)
      file.write(entry_point)
    end
  end
end

# This is a FileUtils extension that defines several additional commands to be
# added to the FileUtils utility functions.
module FileUtils
  def unarchive(archive)
    if Gem.win_platform?
      sh File.absolute_path('tar.exe', Utils.windows_system_directory), '-vxf', archive
    elsif archive.downcase.end_with?('.zip')
      sh 'unzip', '-o', archive
    else
      sh 'tar', '-vxf', archive, '--no-same-owner', '--no-same-permissions'
    end
  end

  def fetch(source_uri, dest_path = nil)
    dest_path = File.basename(source_uri) if dest_path.nil?

    Rake.rake_output_message "fetch #{source_uri}" if Rake::FileUtilsExt.verbose_flag

    unless Rake::FileUtilsExt.nowrite_flag
      data = Utils.fetch_https(source_uri)
      Gem.write_binary(dest_path, data)
    end

    dest_path
  end

  def gem_install(name, version, platform)
    require 'rubygems/remote_fetcher'

    install_dir = File.absolute_path('ruby')

    if Rake::FileUtilsExt.verbose_flag
      Rake.rake_output_message [
        'gem', 'install',
        '--force',
        '--install-dir', install_dir,
        '--no-document', '--ignore-dependencies',
        '--platform', platform,
        '--version', version,
        'sass-embedded'
      ].join(' ')
    end

    dependency = Gem::Dependency.new(name, version)

    dependency_request = Gem::Resolver::DependencyRequest.new(dependency, nil)

    resolver_spec = Gem::Resolver::BestSet.new.find_all(dependency_request).find do |s|
      s.platform == platform
    end

    raise Gem::UnsatisfiableDependencyError, dependency_request if resolver_spec.nil?

    options = { force: true, install_dir: }
    if Rake::FileUtilsExt.nowrite_flag
      installer = Gem::Installer.for_spec(resolver_spec.spec, options)
    else
      path = resolver_spec.download(options)
      installer = Gem::Installer.at(path, options)
      installer.install
    end

    yield installer
  ensure
    rm_rf install_dir unless Rake::FileUtilsExt.nowrite_flag
  end

  def gh_attestation_verify(path, repo:, hostname: 'github.com')
    if SassConfig.development? && system('gh', 'auth', 'status', '--hostname', hostname, %i[out err] => File::NULL)
      sh 'gh', 'attestation', 'verify', path, '--hostname', hostname, '--repo', repo
    end
  end
end

# The {Platform} module.
module Platform
  # @see https://docs.freebsd.org/en/articles/linux-emulation/
  # @see https://docs.freebsd.org/en/books/handbook/linuxemu/
  module Linuxulator
    module_function

    def enabled?
      return false unless RbConfig::CONFIG['host_os'].include?('freebsd')

      return true if defined?(Platform::OS) && Platform::OS.include?('linux')

      begin
        Rake::Task['true'].invoke unless File.exist?('true')
      rescue NotImplementedError
        return false
      end

      system('./true', %i[out err] => File::NULL) == true
    end

    def host_os(root = compat_linux_emul_path)
      return 'linux-none' unless File.symlink?(File.absolute_path('proc/self/exe', root))

      if (Platform::CPU == 'aarch64' &&
          File.exist?(File.absolute_path('lib/ld-linux-aarch64.so.1', root))) ||
         (Platform::CPU == 'riscv64' &&
          File.exist?(File.absolute_path('lib/ld-linux-riscv64-lp64d.so.1', root))) ||
         (Platform::CPU == 'x86_64' &&
          File.exist?(File.absolute_path('lib64/ld-linux-x86-64.so.2', root)))
        return 'linux-gnu'
      end

      if Platform::CPU == 'arm' &&
         File.exist?(File.absolute_path('lib/ld-linux-armhf.so.3', root))
        return 'linux-gnueabihf'
      end

      if %w[aarch64 riscv64 x86_64].include?(Platform::CPU) &&
         File.exist?(File.absolute_path("lib/ld-musl-#{Platform::CPU}.so.1", root))
        return 'linux-musl'
      end

      if Platform::CPU == 'arm' &&
         File.exist?(File.absolute_path('lib/ld-musl-armhf.so.1', root))
        return 'linux-musleabihf'
      end

      if %w[aarch64 riscv64 x86_64].include?(Platform::CPU) &&
         File.exist?(File.absolute_path('system/bin/linker64', root))
        return 'linux-android'
      end

      if Platform::CPU == 'arm' &&
         File.exist?(File.absolute_path('system/bin/linker', root))
        return 'linux-androideabi'
      end

      'linux-none'
    end

    def compat_linux_emul_path
      require 'fiddle'

      lib = Fiddle.dlopen(nil)
      sysctlbyname = Fiddle::Function.new(
        lib['sysctlbyname'],
        [Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP, Fiddle::TYPE_SIZE_T],
        Fiddle::TYPE_INT
      )

      name = Fiddle::Pointer.to_ptr('compat.linux.emul_path')
      oldp = Fiddle::NULL
      oldlenp = Fiddle::Pointer.malloc(Fiddle::SIZEOF_SIZE_T, Fiddle::RUBY_FREE)
      newp = Fiddle::NULL
      newlen = 0
      raise SystemCallError.new(nil, Fiddle.last_error) if sysctlbyname.call(name, oldp, oldlenp, newp, newlen) == -1

      oldp = Fiddle::Pointer.malloc(oldlenp.ptr.to_i, Fiddle::RUBY_FREE)
      raise SystemCallError.new(nil, Fiddle.last_error) if sysctlbyname.call(name, oldp, oldlenp, newp, newlen) == -1

      oldp.to_s
    rescue SystemCallError
      nil
    end
  end

  HOST_CPU = RbConfig::CONFIG['host_cpu'].downcase

  CPU = case HOST_CPU
        when /amd64|x86_64|x64/
          'x86_64'
        when /i\d86|x86|i86pc/
          'i386'
        when /arm64|aarch64/
          'aarch64'
        when /arm/
          'arm'
        when /ppc64le|powerpc64le/
          'ppc64le'
        else
          HOST_CPU
        end

  HOST_OS = (Linuxulator.enabled? ? Linuxulator.host_os : RbConfig::CONFIG['host_os']).downcase

  OS = case HOST_OS
       when /darwin/
         'darwin'
       when /linux-android/
         'linux-android'
       when /linux-musl/
         'linux-musl'
       when /linux-none/
         'linux-none'
       when /linux-uclibc/
         'linux-uclibc'
       when /linux/
         'linux'
       when *Gem::WIN_PATTERNS
         'windows'
       else
         HOST_OS
       end

  ARCH = "#{CPU}-#{OS}".freeze
end

# The {SassConfig} module.
module SassConfig
  module_function

  def package_json(path = '.')
    require 'json'

    JSON.parse(File.read(File.absolute_path('package.json', path)))
  end

  def dart_sass_version
    package_json['dependencies']['sass']
  end

  def dart_sass
    repo = 'https://github.com/sass/dart-sass'

    tag_name = dart_sass_version

    message = "dart-sass for #{Platform::ARCH} not available at #{repo}/releases/tag/#{tag_name}"

    env = ''

    os = case Platform::OS
         when 'darwin'
           'macos'
         when 'linux'
           'linux'
         when 'linux-android'
           'android'
         when 'linux-musl'
           env = '-musl'
           'linux'
         when 'windows'
           'windows'
         else
           raise NotImplementedError, message
         end

    cpu = case Platform::CPU
          when 'x86_64'
            'x64'
          when 'aarch64'
            'arm64'
          when 'arm'
            'arm'
          when 'riscv64'
            'riscv64'
          else
            raise NotImplementedError, message
          end

    ext = Platform::OS == 'windows' ? 'zip' : 'tar.gz'

    "#{repo}/releases/download/#{tag_name}/dart-sass-#{tag_name}-#{os}-#{cpu}#{env}.#{ext}"
  end

  def protoc
    repo = 'https://repo.maven.apache.org/maven2/com/google/protobuf/protoc'

    dependency = Gem::Dependency.new('google-protobuf')

    spec = dependency.to_spec

    version = spec.version

    message = "protoc for #{Platform::ARCH} not available at #{repo}/#{version}"

    os = case Platform::OS
         when 'darwin'
           'osx'
         when 'linux', 'linux-android', 'linux-musl', 'linux-none', 'linux-uclibc'
           'linux'
         when 'windows'
           'windows'
         else
           raise NotImplementedError, message
         end

    cpu = case Platform::CPU
          when 'i386'
            'x86_32'
          when 'x86_64'
            'x86_64'
          when 'aarch64'
            Platform::OS == 'windows' ? 'x86_64' : 'aarch_64'
          when 'ppc64le'
            'ppcle_64'
          when 's390x'
            's390_64'
          else
            raise NotImplementedError, message
          end

    uri = "#{repo}/#{version}/protoc-#{version}-#{os}-#{cpu}.exe"

    Utils.fetch_https("#{uri}.sha1")

    uri
  rescue Gem::RemoteFetcher::FetchError
    dependency_request = Gem::Resolver::DependencyRequest.new(dependency, nil)

    versions = Gem::Resolver::BestSet.new.find_all(dependency_request).filter_map do |s|
      s.version if s.platform == Gem::Platform::RUBY
    end

    versions.sort.reverse_each do |v|
      uri = "#{repo}/#{v}/protoc-#{v}-#{os}-#{cpu}.exe"

      Utils.fetch_https("#{uri}.sha1")

      return uri
    rescue Gem::RemoteFetcher::FetchError
      next
    end

    raise NotImplementedError, message
  end

  def embedded_sass_protocol
    require 'json'

    version = Utils.capture(RbConfig.ruby,
                            File.absolute_path('../../exe/sass', __dir__),
                            '--embedded',
                            '--version')

    tag_name = JSON.parse(version)['protocolVersion']

    "https://github.com/sass/sass/raw/embedded-protocol-#{tag_name}/spec/embedded_sass.proto"
  rescue StandardError # TODO: remove after https://github.com/sass/dart-sass/pull/2413
    'https://github.com/sass/sass/raw/HEAD/spec/embedded_sass.proto'
  end

  def development?
    File.exist?('../../Gemfile')
  end

  def gem_version
    require_relative '../../lib/sass/embedded/version'

    development? ? dart_sass_version : Sass::Embedded::VERSION
  end

  def gem_platform
    platform = Gem::Platform.new("#{Platform::CPU}-#{Platform::HOST_OS}")
    case Platform::OS
    when 'darwin'
      case platform.cpu
      when 'aarch64'
        Gem::Platform.new(['arm64', platform.os])
      else
        platform
      end
    when 'linux'
      if platform.version&.start_with?('gnu')
        platform
      else
        Gem::Platform.new([platform.cpu, platform.os, "gnu#{platform.version}"])
      end
    when 'windows'
      case platform.cpu
      when 'x86_64'
        Gem::Platform.new('x64-mingw-ucrt')
      else
        Gem::Platform.new([platform.cpu, 'mingw', 'ucrt'])
      end
    else
      platform
    end
  end
end

# The {Utils} module.
module Utils
  module_function

  def capture(...)
    require 'open3'

    stdout, stderr, status = Open3.capture3(...)

    raise stderr unless status.success?

    stdout
  end

  def fetch_https(source_uri)
    require 'rubygems/remote_fetcher'

    source_uri = begin
      Gem::Uri.parse!(source_uri)
    rescue NoMethodError
      URI.parse(source_uri)
    end

    Gem::RemoteFetcher.fetcher.fetch_https(source_uri)
  end

  def windows_system_directory
    path = capture('powershell.exe',
                   '-NoLogo',
                   '-NoProfile',
                   '-NonInteractive',
                   '-Command',
                   '[Environment]::GetFolderPath([Environment+SpecialFolder]::System) | Write-Host -NoNewline')

    File.absolute_path(path)
  end
end
