--[[
 Copyright 2019 ARATA Mizuki

 This file is part of ClutTeX.

 ClutTeX is free software: you can redistribute it and/or modify
 it under the terms of the GNU General Public License as published by
 the Free Software Foundation, either version 3 of the License, or
 (at your option) any later version.

 ClutTeX is distributed in the hope that it will be useful,
 but WITHOUT ANY WARRANTY; without even the implied warranty of
 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 GNU General Public License for more details.

 You should have received a copy of the GNU General Public License
 along with ClutTeX.  If not, see <http://www.gnu.org/licenses/>.
]]

local ffi = require "ffi"
local bitlib = assert(bit32 or bit, "Neither bit32 (Lua 5.2) nor bit (LuaJIT) found") -- Lua 5.2 or LuaJIT

ffi.cdef[[
typedef int BOOL;
typedef unsigned int UINT;
typedef uint32_t DWORD;
typedef void *HANDLE;
typedef uintptr_t ULONG_PTR;
typedef uint16_t WCHAR;
typedef struct _OVERLAPPED {
 ULONG_PTR Internal;
 ULONG_PTR InternalHigh;
 union {
   struct {
     DWORD Offset;
     DWORD OffsetHigh;
   };
   void *Pointer;
 };
 HANDLE hEvent;
} OVERLAPPED;
typedef struct _FILE_NOTIFY_INFORMATION {
 DWORD NextEntryOffset;
 DWORD Action;
 DWORD FileNameLength;
 WCHAR FileName[?];
} FILE_NOTIFY_INFORMATION;
typedef void (__stdcall *LPOVERLAPPED_COMPLETION_ROUTINE)(DWORD dwErrorCode, DWORD dwNumberOfBytesTransfered, OVERLAPPED *lpOverlapped);
DWORD GetLastError();
BOOL CloseHandle(HANDLE hObject);
HANDLE CreateFileA(const char *lpFileName, DWORD dwDesiredAccess, DWORD dwShareMode, void *lpSecurityAttributes, DWORD dwCreationDisposition, DWORD dwFlagsAndAttributes, HANDLE hTemplateFile);
HANDLE CreateIoCompletionPort(HANDLE fileHandle, HANDLE existingCompletionPort, ULONG_PTR completionKey, DWORD numberOfConcurrentThreads);
BOOL ReadDirectoryChangesW(HANDLE hDirectory, void *lpBuffer, DWORD nBufferLength, BOOL bWatchSubtree, DWORD dwNotifyFilter, DWORD *lpBytesReturned, OVERLAPPED *lpOverlapped, LPOVERLAPPED_COMPLETION_ROUTINE lpOverlappedCompletionRoutine);
BOOL GetQueuedCompletionStatus(HANDLE CompletionPort, DWORD *lpNumberOfBytes, ULONG_PTR *lpCompletionKey, OVERLAPPED **lpOverlapped, DWORD dwMilliseconds);
int MultiByteToWideChar(UINT CodePage, DWORD dwFlags, const char *lpMultiByteStr, int cbMultiByte, WCHAR *lpWideCharStr, int cchWideChar);
int WideCharToMultiByte(UINT CodePage, DWORD dwFlags, const WCHAR *lpWideCharStr, int cchWideChar, char *lpMultiByteStr, int cbMultiByte, const char *lpDefaultChar, BOOL *lpUsedDefaultChar);
DWORD GetFullPathNameA(const char *lpFileName, DWORD nBufferLength, char *lpBuffer, char **lpFilePart);
uint64_t GetTickCount64();
]]

-- LuaTeX's FFI does not equate a null pointer with nil.
-- On LuaJIT, ffi.NULL is just nil.
local NULL = ffi.NULL

-- GetLastError
local ERROR_FILE_NOT_FOUND         = 0x0002
local ERROR_PATH_NOT_FOUND         = 0x0003
local ERROR_ACCESS_DENIED          = 0x0005
local ERROR_INVALID_PARAMETER      = 0x0057
local ERROR_INSUFFICIENT_BUFFER    = 0x007A
local WAIT_TIMEOUT                 = 0x0102
local ERROR_ABANDONED_WAIT_0       = 0x02DF
local ERROR_NOACCESS               = 0x03E6
local ERROR_INVALID_FLAGS          = 0x03EC
local ERROR_NOTIFY_ENUM_DIR        = 0x03FE
local ERROR_NO_UNICODE_TRANSLATION = 0x0459
local KnownErrors = {
 [ERROR_FILE_NOT_FOUND] = "ERROR_FILE_NOT_FOUND",
 [ERROR_PATH_NOT_FOUND] = "ERROR_PATH_NOT_FOUND",
 [ERROR_ACCESS_DENIED] = "ERROR_ACCESS_DENIED",
 [ERROR_INVALID_PARAMETER] = "ERROR_INVALID_PARAMETER",
 [ERROR_INSUFFICIENT_BUFFER] = "ERROR_INSUFFICIENT_BUFFER",
 [ERROR_ABANDONED_WAIT_0] = "ERROR_ABANDONED_WAIT_0",
 [ERROR_NOACCESS] = "ERROR_NOACCESS",
 [ERROR_INVALID_FLAGS] = "ERROR_INVALID_FLAGS",
 [ERROR_NOTIFY_ENUM_DIR] = "ERROR_NOTIFY_ENUM_DIR",
 [ERROR_NO_UNICODE_TRANSLATION] = "ERROR_NO_UNICODE_TRANSLATION",
}

-- CreateFile
local FILE_FLAG_BACKUP_SEMANTICS = 0x02000000
local FILE_FLAG_OVERLAPPED       = 0x40000000
local OPEN_EXISTING              = 3
local FILE_SHARE_READ            = 0x00000001
local FILE_SHARE_WRITE           = 0x00000002
local FILE_SHARE_DELETE          = 0x00000004
local FILE_LIST_DIRECTORY        = 0x1
local INVALID_HANDLE_VALUE       = ffi.cast("void *", -1)

-- ReadDirectoryChangesW / FILE_NOTIFY_INFORMATION
local FILE_NOTIFY_CHANGE_FILE_NAME   = 0x00000001
local FILE_NOTIFY_CHANGE_DIR_NAME    = 0x00000002
local FILE_NOTIFY_CHANGE_ATTRIBUTES  = 0x00000004
local FILE_NOTIFY_CHANGE_SIZE        = 0x00000008
local FILE_NOTIFY_CHANGE_LAST_WRITE  = 0x00000010
local FILE_NOTIFY_CHANGE_LAST_ACCESS = 0x00000020
local FILE_NOTIFY_CHANGE_CREATION    = 0x00000040
local FILE_NOTIFY_CHANGE_SECURITY    = 0x00000100
local FILE_ACTION_ADDED              = 0x00000001
local FILE_ACTION_REMOVED            = 0x00000002
local FILE_ACTION_MODIFIED           = 0x00000003
local FILE_ACTION_RENAMED_OLD_NAME   = 0x00000004
local FILE_ACTION_RENAMED_NEW_NAME   = 0x00000005

-- WideCharToMultiByte / MultiByteToWideChar
local CP_ACP  = 0
local CP_UTF8 = 65001

local C = ffi.C

local function format_error(name, lasterror, extra)
 local errorname = KnownErrors[lasterror] or string.format("error code %d", lasterror)
 if extra then
   return string.format("%s failed with %s (0x%04x) [%s]", name, errorname, lasterror, extra)
 else
   return string.format("%s failed with %s (0x%04x)", name, errorname, lasterror)
 end
end
local function wcs_to_mbs(wstr, wstrlen, codepage)
 -- wstr: FFI uint16_t[?]
 -- wstrlen: length of wstr, or -1 if NUL-terminated
 if wstrlen == 0 then
   return ""
 end
 codepage = codepage or CP_ACP
 local dwFlags = 0
 local result = C.WideCharToMultiByte(codepage, dwFlags, wstr, wstrlen, nil, 0, nil, nil)
 if result <= 0 then
   -- Failed
   local lasterror = C.GetLastError()
   -- Candidates: ERROR_INSUFFICIENT_BUFFER, ERROR_INVALID_FLAGS, ERROR_INVALID_PARAMETER, ERROR_NO_UNICODE_TRANSLATION
   return nil, format_error("WideCharToMultiByte", lasterror)
 end
 local mbsbuf = ffi.new("char[?]", result)
 result = C.WideCharToMultiByte(codepage, dwFlags, wstr, wstrlen, mbsbuf, result, nil, nil)
 if result <= 0 then
   -- Failed
   local lasterror = C.GetLastError()
   -- Candidates: ERROR_INSUFFICIENT_BUFFER, ERROR_INVALID_FLAGS, ERROR_INVALID_PARAMETER, ERROR_NO_UNICODE_TRANSLATION
   return nil, format_error("WideCharToMultiByte", lasterror)
 end
 return ffi.string(mbsbuf, result)
end
local function mbs_to_wcs(str, codepage)
 -- str: Lua string
 if str == "" then
   return ffi.new("WCHAR[0]")
 end
 codepage = codepage or CP_ACP
 local dwFlags = 0
 local result = C.MultiByteToWideChar(codepage, dwFlags, str, #str, nil, 0)
 if result <= 0 then
   local lasterror = C.GetLastError()
   -- ERROR_INSUFFICIENT_BUFFER, ERROR_INVALID_FLAGS, ERROR_INVALID_PARAMETER, ERROR_NO_UNICODE_TRANSLATION
   return nil, format_error("MultiByteToWideChar", lasterror)
 end
 local wcsbuf = ffi.new("WCHAR[?]", result)
 result = C.MultiByteToWideChar(codepage, dwFlags, str, #str, wcsbuf, result)
 if result <= 0 then
   local lasterror = C.GetLastError()
   return nil, format_error("MultiByteToWideChar", lasterror)
 end
 return wcsbuf, result
end

-- TEST CODE
do
 local ws = {0x3042}
 local resultstr = wcs_to_mbs(ffi.new("WCHAR[1]", ws), 1, CP_UTF8)
 assert(#resultstr == 3)
 assert(resultstr == "\xE3\x81\x82") -- \u{XXXX} notation is not available on LuaJIT
end
-- END TEST CODE

local function get_full_path_name(filename)
 local bufsize = 1024
 local buffer
 local filePartPtr = ffi.new("char*[1]")
 local result
 repeat
   buffer = ffi.new("char[?]", bufsize)
   result = C.GetFullPathNameA(filename, bufsize, buffer, filePartPtr)
   if result == 0 then
     local lasterror = C.GetLastError()
     return nil, format_error("GetFullPathNameA", lasterror, filename)
   elseif bufsize < result then
     -- result: buffer size required to hold the path + terminating NUL
     bufsize = result
   end
 until result < bufsize
 local fullpath = ffi.string(buffer, result)
 local filePart = ffi.string(filePartPtr[0])
 local dirPart = ffi.string(buffer, ffi.cast("intptr_t", filePartPtr[0]) - ffi.cast("intptr_t", buffer)) -- LuaTeX's FFI doesn't support pointer subtraction
 return fullpath, filePart, dirPart
end

--[[
 dirwatche.dirname : string
 dirwatcher._rawhandle : cdata HANDLE
 dirwatcher._overlapped : cdata OVERLAPPED
 dirwatcher._buffer : cdata char[?]
]]
local dirwatcher_meta = {}
dirwatcher_meta.__index = dirwatcher_meta
function dirwatcher_meta:close()
 if self._rawhandle ~= nil then
   C.CloseHandle(ffi.gc(self._rawhandle, nil))
   self._rawhandle = nil
 end
end
local function open_directory(dirname)
 local dwShareMode = bitlib.bor(FILE_SHARE_READ, FILE_SHARE_WRITE, FILE_SHARE_DELETE)
 local dwFlagsAndAttributes = bitlib.bor(FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OVERLAPPED)
 local handle = C.CreateFileA(dirname, FILE_LIST_DIRECTORY, dwShareMode, nil, OPEN_EXISTING, dwFlagsAndAttributes, nil)
 if handle == INVALID_HANDLE_VALUE then
   local lasterror = C.GetLastError()
   print("Failed to open "..dirname)
   return nil, format_error("CreateFileA", lasterror, dirname)
 end
 return setmetatable({
   dirname = dirname,
   _rawhandle = ffi.gc(handle, C.CloseHandle),
   _overlapped = ffi.new("OVERLAPPED"),
   _buffer = ffi.new("char[?]", 1024),
 }, dirwatcher_meta)
end
function dirwatcher_meta:start_watch(watchSubtree)
 local dwNotifyFilter = bitlib.bor(FILE_NOTIFY_CHANGE_FILE_NAME, FILE_NOTIFY_CHANGE_DIR_NAME, FILE_NOTIFY_CHANGE_ATTRIBUTES, FILE_NOTIFY_CHANGE_SIZE, FILE_NOTIFY_CHANGE_LAST_WRITE, FILE_NOTIFY_CHANGE_LAST_ACCESS, FILE_NOTIFY_CHANGE_CREATION, FILE_NOTIFY_CHANGE_SECURITY)
 local buffer = self._buffer
 local bufferSize = ffi.sizeof(buffer)
 local result = C.ReadDirectoryChangesW(self._rawhandle, buffer, bufferSize, watchSubtree, dwNotifyFilter, nil, self._overlapped, nil)
 if result == 0 then
   local lasterror = C.GetLastError()
   return nil, format_error("ReadDirectoryChangesW", lasterror, self.dirname)
 end
 return true
end
local ActionTable = {
 [FILE_ACTION_ADDED] = "added",
 [FILE_ACTION_REMOVED] = "removed",
 [FILE_ACTION_MODIFIED] = "modified",
 [FILE_ACTION_RENAMED_OLD_NAME] = "rename_from",
 [FILE_ACTION_RENAMED_NEW_NAME] = "rename_to",
}
function dirwatcher_meta:process(numberOfBytes)
 -- self._buffer received `numberOfBytes` bytes
 local buffer = self._buffer
 numberOfBytes = math.min(numberOfBytes, ffi.sizeof(buffer))
 local ptr = ffi.cast("char *", buffer)
 local structSize = ffi.sizeof("FILE_NOTIFY_INFORMATION", 1)
 local t = {}
 while numberOfBytes >= structSize do
   local notifyInfo = ffi.cast("FILE_NOTIFY_INFORMATION*", ptr)
   local nextEntryOffset = notifyInfo.NextEntryOffset
   local action = notifyInfo.Action
   local fileNameLength = notifyInfo.FileNameLength
   local fileName = notifyInfo.FileName
   local u = { action = ActionTable[action], filename = wcs_to_mbs(fileName, fileNameLength / 2) }
   table.insert(t, u)
   if nextEntryOffset == 0 or numberOfBytes <= nextEntryOffset then
     break
   end
   numberOfBytes = numberOfBytes - nextEntryOffset
   ptr = ptr + nextEntryOffset
 end
 return t
end

--[[
 watcher._rawport : cdata HANDLE
 watcher._pending : array of {
   action = ..., filename = ...
 }
 watcher._directories[dirname] = {
   dir = directory watcher,
   dirname = dirname,
   files = { [filename] = user-supplied path } -- files to watch
 }
 watcher[i] = i-th directory (_directories[dirname] for some dirname)
]]

local fswatcher_meta = {}
fswatcher_meta.__index = fswatcher_meta
local function new_watcher()
 local port = C.CreateIoCompletionPort(INVALID_HANDLE_VALUE, nil, 0, 0)
 if port == NULL then
   local lasterror = C.GetLastError()
   return nil, format_error("CreateIoCompletionPort", lasterror)
 end
 return setmetatable({
   _rawport = ffi.gc(port, C.CloseHandle), -- ?
   _pending = {},
   _directories = {},
 }, fswatcher_meta)
end
local function add_directory(self, dirname)
 local t = self._directories[dirname]
 if not t then
   local dirwatcher, err = open_directory(dirname)
   if not dirwatcher then
     return dirwatcher, err
   end
   t = { dirwatcher = dirwatcher, dirname = dirname, files = {} }
   table.insert(self, t)
   local i = #self
   local result = C.CreateIoCompletionPort(dirwatcher._rawhandle, self._rawport, i, 0)
   if result == NULL then
     local lasterror = C.GetLastError()
     return nil, format_error("CreateIoCompletionPort", lasterror, dirname)
   end
   self._directories[dirname] = t
   local result, err = dirwatcher:start_watch(false)
   if not result then
     return result, err
   end
 end
 return t
end
function fswatcher_meta:add_file(path, ...)
 local fullpath, filename, dirname = get_full_path_name(path)
 local t, err = add_directory(self, dirname)
 if not t then
   return t, err
 end
 t.files[filename] = path
 return true
end
local INFINITE = 0xFFFFFFFF
local function get_queued(self, timeout)
 local startTime = C.GetTickCount64()
 local timeout_ms
 if timeout == nil then
   timeout_ms = INFINITE
 else
   timeout_ms = timeout * 1000
 end
 local numberOfBytesPtr = ffi.new("DWORD[1]")
 local completionKeyPtr = ffi.new("ULONG_PTR[1]")
 local lpOverlapped = ffi.new("OVERLAPPED*[1]")
 repeat
   local result = C.GetQueuedCompletionStatus(self._rawport, numberOfBytesPtr, completionKeyPtr, lpOverlapped, timeout_ms)
   if result == 0 then
     local lasterror = C.GetLastError()
     if lasterror == WAIT_TIMEOUT then
       return nil, "timeout"
     else
       return nil, format_error("GetQueuedCompletionStatus", lasterror)
     end
   end
   local numberOfBytes = numberOfBytesPtr[0]
   local completionKey = tonumber(completionKeyPtr[0])
   local dir_t = assert(self[completionKey], "invalid completion key: " .. tostring(completionKey))
   local t = dir_t.dirwatcher:process(numberOfBytes)
   dir_t.dirwatcher:start_watch(false)
   local found = false
   for i,v in ipairs(t) do
     local path = dir_t.files[v.filename]
     if path then
       found = true
       table.insert(self._pending, {path = path, action = v.action})
     end
   end
   if found then
     return true
   end
   if timeout_ms ~= INFINITE then
     local tt = C.GetTickCount64()
     timeout_ms = timeout_ms - (tt - startTime)
     startTime = tt
   end
 until timeout_ms < 0
 return nil, "timeout"
end
function fswatcher_meta:next(timeout)
 if #self._pending > 0 then
   local result = table.remove(self._pending, 1)
   get_queued(self, 0) -- ignore error
   return result
 else
   local result, err = get_queued(self, timeout)
   if result == nil then
     return nil, err
   end
   return table.remove(self._pending, 1)
 end
end
function fswatcher_meta:close()
 if self._rawport ~= nil then
   for i,v in ipairs(self) do
     v.dirwatcher:close()
   end
   C.CloseHandle(ffi.gc(self._rawport, nil))
   self._rawport = nil
 end
end
--[[
local watcher = require("fswatcher_windows").new()
assert(watcher:add_file("rdc-sync.c"))
assert(watcher:add_file("sub2/hoge"))
for i = 1, 10 do
   local result, err = watcher:next(2)
   if err == "timeout" then
       print(os.date(), "timeout")
   else
       assert(result, err)
       print(os.date(), result.path, result.action)
   end
end
watcher:close()
]]
return {
 new = new_watcher,
}