-- Detect the operating system and architecture without forking a subprocess.
--
-- We are not going for exhaustive list of every historical system here,
-- but aiming to cover every platform where LuaRocks is known to run.
-- If your system is not detected, patches are welcome!

local sysdetect = {}

local function hex(s)
   return s:gsub("$(..)", function(x)
      return string.char(tonumber(x, 16))
   end)
end

local function read_int8(fd)
   if io.type(fd) == "closed file" then
      return nil
   end   
   local s = fd:read(1)
   if not s then
      fd:close()
      return nil
   end
   return s:byte()
end

local LITTLE = 1
-- local BIG = 2

local function bytes2number(s, endian)
   local r = 0
   if endian == LITTLE then
      for i = #s, 1, -1 do
         r = r*256 + s:byte(i,i)
      end
   else
      for i = 1, #s do
         r = r*256 + s:byte(i,i)
      end
   end
   return r
end

local function read(fd, bytes, endian)
   if io.type(fd) == "closed file" then
      return nil
   end   
   local s = fd:read(bytes)
   if not s
      then fd:close()
      return nil
   end
   return bytes2number(s, endian)
end

local function read_int32le(fd)
   return read(fd, 4, LITTLE)
end

--------------------------------------------------------------------------------
-- @section ELF
--------------------------------------------------------------------------------

local e_osabi = {
   [0x00] = "sysv",
   [0x01] = "hpux",
   [0x02] = "netbsd",
   [0x03] = "linux",
   [0x04] = "hurd",
   [0x06] = "solaris",
   [0x07] = "aix",
   [0x08] = "irix",
   [0x09] = "freebsd",
   [0x0c] = "openbsd",
}

local e_machines = {
   [0x02] = "sparc",
   [0x03] = "x86",
   [0x08] = "mips",
   [0x0f] = "hppa",
   [0x12] = "sparcv8",
   [0x14] = "ppc",
   [0x15] = "ppc64",
   [0x16] = "s390",
   [0x28] = "arm",
   [0x2a] = "superh",
   [0x2b] = "sparcv9",
   [0x32] = "ia_64",
   [0x3E] = "x86_64",
   [0xB6] = "alpha",
   [0xB7] = "aarch64",
   [0xF3] = "riscv64",
   [0x9026] = "alpha",
}

local SHT_NOTE = 7

local function read_elf_section_headers(fd, hdr)
   local endian = hdr.endian
   local word = hdr.word

   local strtab_offset
   local sections = {}
   for i = 0, hdr.e_shnum - 1 do
      fd:seek("set", hdr.e_shoff + (i * hdr.e_shentsize))
      local section = {}
      section.sh_name_off = read(fd, 4, endian)
      section.sh_type = read(fd, 4, endian)
      section.sh_flags = read(fd, word, endian)
      section.sh_addr = read(fd, word, endian)
      section.sh_offset = read(fd, word, endian)
      section.sh_size = read(fd, word, endian)
      section.sh_link = read(fd, 4, endian)
      section.sh_info = read(fd, 4, endian)
      if section.sh_type == SHT_NOTE then
         fd:seek("set", section.sh_offset)
         section.namesz = read(fd, 4, endian)
         section.descsz = read(fd, 4, endian)
         section.type = read(fd, 4, endian)
         section.namedata = fd:read(section.namesz):gsub("%z.*", "")
         section.descdata = fd:read(section.descsz)
      elseif i == hdr.e_shstrndx then
         strtab_offset = section.sh_offset
      end
      table.insert(sections, section)
   end
   if strtab_offset then
      for _, section in ipairs(sections) do
         fd:seek("set", strtab_offset + section.sh_name_off)
         section.name = fd:read(32):gsub("%z.*", "")
         sections[section.name] = section
      end
   end
   return sections
end

local function detect_elf_system(fd, hdr, sections)
   local system = e_osabi[hdr.osabi]
   local endian = hdr.endian
   
   if system == "sysv" then
      local abitag = sections[".note.ABI-tag"]
      if abitag then
         if abitag.namedata == "GNU" and abitag.type == 1
           and abitag.descdata:sub(0, 4) == "\0\0\0\0" then
            return "linux"
         end
      elseif sections[".SUNW_version"]
        or sections[".SUNW_signature"] then
         return "solaris"
      elseif sections[".note.netbsd.ident"] then
         return "netbsd"
      elseif sections[".note.openbsd.ident"] then
         return "openbsd"
      end
      
      local gnu_version_r = sections[".gnu.version_r"]
      if gnu_version_r then

         local dynstr = sections[".dynstr"].sh_offset

         local idx = 0
         for _ = 0, gnu_version_r.sh_info - 1 do
            fd:seek("set", gnu_version_r.sh_offset + idx)
            assert(read(fd, 2, endian)) -- vn_version
            local vn_cnt = read(fd, 2, endian)
            local vn_file = read(fd, 4, endian)
            local vn_next = read(fd, 2, endian)

            fd:seek("set", dynstr + vn_file)
            local libname = fd:read(64):gsub("%z.*", "")
            
            if hdr.e_type == 0x03 and libname == "libroot.so" then
               return "haiku"
            elseif libname:match("linux") then
               return "linux"
            end

            idx = idx + (vn_next * (vn_cnt + 1))
         end
      end

      local procfile = io.open("/proc/sys/kernel/ostype")
      if procfile then
         local version = procfile:read(6)
         procfile:close()
         if version == "Linux\n" then
            return "linux"
         end
      end
   end
   
   return system
end

local function read_elf_header(fd)
   local hdr = {}

   hdr.bits = read_int8(fd)
   hdr.endian = read_int8(fd)
   hdr.elf_version = read_int8(fd)
   if hdr.elf_version ~= 1 then
      return nil
   end
   hdr.osabi = read_int8(fd)
   if not hdr.osabi then
      return nil
   end
   
   local endian = hdr.endian
   fd:seek("set", 0x10)
   hdr.e_type = read(fd, 2, endian)
   local machine = read(fd, 2, endian)
   local processor = e_machines[machine] or "unknown"
   if endian == 1 and processor == "ppc64" then
      processor = "ppc64le"
   end

   local elfversion = read(fd, 4, endian)
   if elfversion ~= 1 then
      return nil
   end
   
   local word = (hdr.bits == 1) and 4 or 8
   hdr.word = word
   
   hdr.e_entry = read(fd, word, endian)
   hdr.e_phoff = read(fd, word, endian)
   hdr.e_shoff = read(fd, word, endian)
   hdr.e_flags = read(fd, 4, endian)
   hdr.e_ehsize = read(fd, 2, endian)
   hdr.e_phentsize = read(fd, 2, endian)
   hdr.e_phnum = read(fd, 2, endian)
   hdr.e_shentsize = read(fd, 2, endian)
   hdr.e_shnum = read(fd, 2, endian)
   hdr.e_shstrndx = read(fd, 2, endian)
   
   return hdr, processor
end

local function detect_elf(fd)
   local hdr, processor = read_elf_header(fd)
   if not hdr then
      return nil
   end
   local sections = read_elf_section_headers(fd, hdr)
   local system = detect_elf_system(fd, hdr, sections)
   return system, processor
end

--------------------------------------------------------------------------------
-- @section Mach Objects (Apple)
--------------------------------------------------------------------------------

local mach_l64 = {
   [7] = "x86_64",
   [12] = "aarch64",
}

local mach_b64 = {
   [0] = "ppc64",
}

local mach_l32 = {
   [7] = "x86",
   [12] = "arm",
}

local mach_b32 = {
   [0] = "ppc",
}

local function detect_mach(magic, fd)
   if not magic then
      return nil
   end

   if magic == hex("$CA$FE$BA$BE") then
      -- fat binary, go for the first one
      fd:seek("set", 0x12)
      local offs = read_int8(fd)
      if not offs then
         return nil
      end
      fd:seek("set", offs * 256)
      magic = fd:read(4)
      return detect_mach(magic, fd)
   end

   local cputype = read_int8(fd)

   if magic == hex("$CF$FA$ED$FE") then
      return "macosx", mach_l64[cputype] or "unknown"
   elseif magic == hex("$FE$ED$CF$FA") then
      return "macosx", mach_b64[cputype] or "unknown"
   elseif magic == hex("$CE$FA$ED$FE") then
      return "macosx", mach_l32[cputype] or "unknown"
   elseif magic == hex("$FE$ED$FA$CE") then
      return "macosx", mach_b32[cputype] or "unknown"
   end
end

--------------------------------------------------------------------------------
-- @section PE (Windows)
--------------------------------------------------------------------------------

local pe_machine = {
    [0x8664] = "x86_64",
    [0x01c0] = "arm",
    [0x01c4] = "armv7l",
    [0xaa64] = "arm64",
    [0x014c] = "x86",
}

local function detect_pe(fd)
   fd:seek("set", 60)                 -- position of PE header position
   local peoffset = read_int32le(fd)  -- read position of PE header
   if not peoffset then
      return nil
   end
   local system = "windows"
   fd:seek("set", peoffset + 4)       -- move to position of Machine section
   local machine = read(fd, 2, LITTLE)
   local processor = pe_machine[machine]
  
   local rdata_pos = fd:read(736):match(".rdata%z%z............(....)")
   if rdata_pos then
      rdata_pos = bytes2number(rdata_pos, LITTLE)
      fd:seek("set", rdata_pos)
      local data = fd:read(512)
      if data:match("cyggcc") then
         system = "cygwin"
      end
   end
   
   return system, processor or "unknown"
end

--------------------------------------------------------------------------------
-- @section API
--------------------------------------------------------------------------------

function sysdetect.detect_file(file)
   assert(type(file) == "string")
   local fd = io.open(file, "rb")
   if not fd then
      return nil
   end
   local magic = fd:read(4)
   if magic == hex("$7FELF") then
      return detect_elf(fd)
   end
   if magic == hex("MZ$90$00") then
      return detect_pe(fd)
   end
   return detect_mach(magic, fd)
end

local cache_system
local cache_processor

function sysdetect.detect(input_file)
   local dirsep = package.config:sub(1,1)
   local files
   
   if input_file then
      files = { input_file }
   else
      if cache_system then
         return cache_system, cache_processor
      end

      local PATHsep
      local interp = arg and arg[-1]
      if dirsep == "/" then
         -- Unix
         files = {
            "/bin/sh", -- Unix: well-known POSIX path
            "/proc/self/exe", -- Linux: this should always have a working binary
         }
         PATHsep = ":"
      else
         -- Windows
         local systemroot = os.getenv("SystemRoot")
         files = {
            systemroot .. "\\system32\\notepad.exe", -- well-known Windows path
            systemroot .. "\\explorer.exe", -- well-known Windows path
         }
         if interp and not interp:lower():match("exe$") then
            interp = interp .. ".exe"
         end
         PATHsep = ";"
      end
      if interp then
         if interp:match(dirsep) then
            -- interpreter path is absolute
            table.insert(files, interp)
         else
            for d in os.getenv("PATH"):gmatch("[^"..PATHsep.."]+") do
               table.insert(files, d .. dirsep .. interp)
            end
         end
      end
   end
   for _, f in ipairs(files) do
      local system, processor = sysdetect.detect_file(f)
      if system then
         cache_system = system
         cache_processor = processor
         return system, processor
      end
   end
end

return sysdetect
