#include "wdm.h"

#include "utils.h"
#include "entry.h"
#include "queue.h"
#include "monitor.h"

#include "rb_change.h"
#include "rb_monitor.h"

// ----------------------------------------------------------
// Global variables
// ----------------------------------------------------------

VALUE cWDM_Monitor;

VALUE eWDM_UnknownFlagError;

// ----------------------------------------------------------
// Cached variables
// ----------------------------------------------------------

static ID wdm_rb_sym_call;
static ID wdm_rb_sym_files;
static ID wdm_rb_sym_directories;
static ID wdm_rb_sym_attributes;
static ID wdm_rb_sym_size;
static ID wdm_rb_sym_last_write;
static ID wdm_rb_sym_last_access;
static ID wdm_rb_sym_creation;
static ID wdm_rb_sym_security;
static ID wdm_rb_sym_default;

// ---------------------------------------------------------
// Prototypes of static functions
// ---------------------------------------------------------

static void monitor_mark(LPVOID);
static void monitor_free(LPVOID);
static VALUE rb_monitor_alloc(VALUE);

static DWORD id_to_flag(ID);
static DWORD extract_flags_from_rb_array(VALUE);
static VALUE combined_watch(BOOL, int, VALUE*, VALUE);
static VALUE rb_monitor_watch(int, VALUE*, VALUE);

static VALUE rb_monitor_watch_recursively(int, VALUE*, VALUE);

static void CALLBACK handle_entry_change(DWORD, DWORD, LPOVERLAPPED);
static BOOL register_monitoring_entry(WDM_PEntry);
static DWORD WINAPI start_monitoring(LPVOID);

static void *wait_for_changes(void *);
static void process_changes(WDM_PQueue);
static void stop_monitoring(LPVOID);
static VALUE rb_monitor_run_bang(VALUE);

static VALUE rb_monitor_stop(VALUE);

// ----------------------------------------------------------

static void
monitor_mark(LPVOID param)
{
    WDM_PMonitor monitor;
    WDM_PEntry entry;

    monitor = (WDM_PMonitor)param;
    entry = monitor->head;

    while(entry != NULL) {
        rb_gc_mark(entry->user_data->callback);
        entry = entry->next;
    }
}

static void
monitor_free(LPVOID param)
{
    WDM_PMonitor monitor;
    WDM_PEntry entry;

    WDM_DEBUG("Freeing a monitor object!");

    monitor = (WDM_PMonitor)param;
    entry = monitor->head;

    stop_monitoring(monitor); // If the monitor is already stopped, it would do nothing

    while(entry != NULL) {
        if ( entry->event_container.hEvent != NULL ) {
            wdm_monitor_callback_param_free(
                (WDM_PMonitorCallbackParam)entry->event_container.hEvent
            );
        }
        entry = entry->next;
    }

    wdm_monitor_free(monitor);
}

static const rb_data_type_t monitor_data_type = {
    .wrap_struct_name = "WDM::Monitor",
    .function = {
        .dmark = monitor_mark,
        .dfree = monitor_free,
        .dsize = NULL,
    },
    .flags = 0
};

static VALUE
rb_monitor_alloc(VALUE self)
{
    WDM_DEBUG("--------------------------------");
    WDM_DEBUG("Allocating a new monitor object!");
    WDM_DEBUG("--------------------------------");

    return TypedData_Wrap_Struct(self, &monitor_data_type, wdm_monitor_new());
}

static DWORD
id_to_flag(ID id)
{
    if ( id == wdm_rb_sym_default ) return WDM_MONITOR_FLAGS_DEFAULT;

    // TODO: Maybe reorder the if's in the frequency of use for better performance?
    if ( id == wdm_rb_sym_files ) return FILE_NOTIFY_CHANGE_FILE_NAME;
    if ( id == wdm_rb_sym_directories ) return FILE_NOTIFY_CHANGE_DIR_NAME;
    if ( id == wdm_rb_sym_attributes ) return FILE_NOTIFY_CHANGE_ATTRIBUTES;
    if ( id == wdm_rb_sym_size ) return FILE_NOTIFY_CHANGE_SIZE;
    if ( id == wdm_rb_sym_last_write ) return FILE_NOTIFY_CHANGE_LAST_WRITE;
    if ( id == wdm_rb_sym_last_access ) return FILE_NOTIFY_CHANGE_LAST_ACCESS;
    if ( id == wdm_rb_sym_creation ) return FILE_NOTIFY_CHANGE_CREATION;
    if ( id == wdm_rb_sym_security ) return FILE_NOTIFY_CHANGE_SECURITY;

    rb_raise(eWDM_UnknownFlagError, "Unknown watch flag: ':%s'", rb_id2name(id));
}

static DWORD
extract_flags_from_rb_array(VALUE flags_array)
{
    VALUE flag_symbol;
    DWORD flags;

    flags = 0;

    while ( RARRAY_LEN(flags_array) != 0 ) {
        flag_symbol = rb_ary_pop(flags_array);
        Check_Type(flag_symbol, T_SYMBOL);
        flags |= id_to_flag( SYM2ID(flag_symbol) );
    }

    return flags;
}

static VALUE
combined_watch(BOOL recursively, int argc, VALUE *argv, VALUE self)
{
    WDM_PMonitor monitor;
    WDM_PEntry entry;
    int directory_letters_count;
    VALUE directory, flags, os_encoded_directory;
    BOOL running;

    // TODO: Maybe raise a more user-friendly error?
    rb_need_block();

    TypedData_Get_Struct(self, WDM_Monitor, &monitor_data_type, monitor);

    EnterCriticalSection(&monitor->lock);
        running = monitor->running;
    LeaveCriticalSection(&monitor->lock);

    if ( running ) {
        rb_raise(eWDM_MonitorRunningError, "You can't watch new directories while the monitor is running!");
    }

    rb_scan_args(argc, argv, "1*", &directory, &flags);

    Check_Type(directory, T_STRING);

    entry = wdm_entry_new();
    entry->user_data->watch_childeren = recursively;
    entry->user_data->callback =  rb_block_proc();
    entry->user_data->flags = RARRAY_LEN(flags) == 0 ? WDM_MONITOR_FLAGS_DEFAULT : extract_flags_from_rb_array(flags);

    // WTF Ruby source: The original code (file.c) uses the following macro to make sure that the encoding
    // of the string is ASCII-compatible, but UTF-16LE (Windows default encoding) is not!!!
    //
    // FilePathValue(directory);

    os_encoded_directory = rb_str_encode_ospath(directory);

    // RSTRING_LEN can't be used because it would return the count of bytes the string uses in its encoding (like UTF-8).
    // UTF-8 might use more than one byte for the char, which is not needed for WCHAR strings.
    // Also, the result of MultiByteToWideChar _includes_ the NULL char at the end, which is not true for RSTRING.
    //
    // Example: 'C:\Users\Maher\Desktop\تجربة' with __ENCODING__ == UTF-8
    //   MultiByteToWideChar => 29 (28-char + null)
    //   RSTRING_LEN => 33 (23-char + 10-bytes for 5 Arabic letters which take 2 bytes each)
    //
    directory_letters_count = MultiByteToWideChar(CP_UTF8, 0, RSTRING_PTR(os_encoded_directory), -1, NULL, 0);

    entry->user_data->dir = ALLOCA_N(WCHAR, directory_letters_count);

    MultiByteToWideChar(CP_UTF8, 0, RSTRING_PTR(os_encoded_directory), -1, entry->user_data->dir, directory_letters_count);

    WDM_DEBUG("New path to watch: '%S'", entry->user_data->dir);

    entry->user_data->dir = wdm_utils_full_pathname(entry->user_data->dir);

    if ( entry->user_data->dir == 0 ) {
        wdm_entry_free(entry);
        rb_raise(eWDM_Error, "Can't get the absolute path for the passed directory: '%s'!", RSTRING_PTR(directory));
    }

    if ( ! wdm_utils_unicode_is_directory(entry->user_data->dir) ) {
        wdm_entry_free(entry);
        rb_raise(eWDM_InvalidDirectoryError, "No such directory: '%s'!", RSTRING_PTR(directory));
    }

    entry->dir_handle = CreateFileW(
        entry->user_data->dir,     // pointer to the file name
        FILE_LIST_DIRECTORY,       // access (read/write) mode
        FILE_SHARE_READ            // share mode
            | FILE_SHARE_WRITE
            | FILE_SHARE_DELETE,
        NULL,                       // security descriptor
        OPEN_EXISTING,              // how to create
        FILE_FLAG_BACKUP_SEMANTICS
            | FILE_FLAG_OVERLAPPED, // file attributes
        NULL
    );

    if ( entry->dir_handle ==  INVALID_HANDLE_VALUE ) {
        wdm_entry_free(entry);
        rb_raise(eWDM_Error, "Can't watch directory: '%s'!", RSTRING_PTR(directory));
    }

    // Store a reference to the entry instead of an event as the event
    // won't be used when using callbacks.
    entry->event_container.hEvent = wdm_monitor_callback_param_new(monitor, entry);

    wdm_monitor_update_head(monitor, entry);

    WDM_DEBUG("Watching directory: '%S'", entry->user_data->dir);

    return Qnil;
}

static VALUE
rb_monitor_watch(int argc, VALUE *argv, VALUE self)
{
    return combined_watch(FALSE, argc, argv, self);
}

static VALUE
rb_monitor_watch_recursively(int argc, VALUE *argv, VALUE self)
{
    return combined_watch(TRUE, argc, argv, self);
}

static void CALLBACK
handle_entry_change(
    DWORD err_code,                 // completion code
    DWORD bytes_transfered,         // number of bytes transferred
    LPOVERLAPPED event_container    // I/O information buffer
) {
    WDM_PMonitorCallbackParam param;
    WDM_PQueueItem data_to_process;

    if ( err_code == ERROR_OPERATION_ABORTED ) {
        // Async operation was canceled. This shouldn't happen.
        // TODO:
        //   1. Maybe add a union in the queue for errors?
        //   2. What's the best action when this happens?
        WDM_DEBUG("Dir handler closed in the process callback!");
        return;
    }

    if ( ! bytes_transfered ) {
        WDM_DEBUG("Buffer overflow?! Changes are bigger than the buffer!");
        return;
    }

    param = (WDM_PMonitorCallbackParam)event_container->hEvent;
    data_to_process = wdm_queue_item_new(WDM_QUEUE_ITEM_TYPE_DATA);
    data_to_process->data = wdm_queue_item_data_new();

    WDM_DEBUG("Change detected in '%S'", param->entry->user_data->dir);

    data_to_process->data->user_data = param->entry->user_data;

    // Copy change data to the backup buffer
    memcpy(data_to_process->data->buffer, param->entry->buffer, bytes_transfered);

    // Add the backup buffer to the change queue
    wdm_queue_enqueue(param->monitor->changes, data_to_process);

    // Resume watching the dir for changes
    register_monitoring_entry(param->entry);

    // Tell the processing thread to process the changes
    if ( WaitForSingleObject(param->monitor->process_event, 0) != WAIT_OBJECT_0 ) { // Check if already signaled
        SetEvent(param->monitor->process_event);
    }
}

static BOOL
register_monitoring_entry(WDM_PEntry entry)
{
    BOOL success;
    DWORD bytes;
    bytes = 0; // Not used because the process callback gets passed the written bytes

    success = ReadDirectoryChangesW(
        entry->dir_handle,                  // handle to directory
        entry->buffer,                      // read results buffer
        WDM_BUFFER_SIZE,                    // length of buffer
        entry->user_data->watch_childeren,  // monitoring option
        entry->user_data->flags,            // filter conditions
        &bytes,                             // bytes returned
        &entry->event_container,            // overlapped buffer
        &handle_entry_change                // process callback
    );

    if ( ! success ) {
        WDM_DEBUG("ReadDirectoryChangesW failed with error (%d): %s", GetLastError(), rb_w32_strerror(GetLastError()));
        return FALSE;
    }

    return TRUE;
}

static DWORD WINAPI
start_monitoring(LPVOID param)
{
    WDM_PMonitor monitor;
    WDM_PEntry curr_entry;

    monitor = (WDM_PMonitor)param;
    curr_entry = monitor->head;

    WDM_DEBUG("Starting the monitoring thread!");

    while(curr_entry != NULL) {
        if ( ! register_monitoring_entry(curr_entry) ) {
            WDM_PQueueItem error_item;
            int directory_bytes;
            LPSTR multibyte_directory;

            directory_bytes = WideCharToMultiByte(CP_UTF8, 0, curr_entry->user_data->dir, -1, NULL, 0, NULL, NULL);
            multibyte_directory = ALLOCA_N(CHAR, directory_bytes);
            WideCharToMultiByte(CP_UTF8, 0, curr_entry->user_data->dir, -1, multibyte_directory, directory_bytes, NULL, NULL);

            error_item = wdm_queue_item_new(WDM_QUEUE_ITEM_TYPE_ERROR);
            error_item->error = wdm_queue_item_error_new(
                eWDM_UnwatchableDirectoryError, "Can't watch directory: '%s'!", multibyte_directory
            );

            wdm_queue_enqueue(monitor->changes, error_item);
            SetEvent(monitor->process_event);
        }

        curr_entry = curr_entry->next;
    }

    while(monitor->running) {
        // TODO: Is this the best way to do it?
        if ( WaitForSingleObjectEx(monitor->stop_event, INFINITE, TRUE) == WAIT_OBJECT_0) {
            WDM_DEBUG("Exiting the monitoring thread!");
            ExitThread(0);
        }
    }

    return 0;
}

static void *
wait_for_changes(void *param)
{
    HANDLE process_event = (HANDLE)param;
    VALUE rb_res;

    rb_res = WaitForSingleObject(process_event, INFINITE) == WAIT_OBJECT_0 ? Qtrue : Qfalse;
    return (void *)rb_res;
}

static void
process_changes(WDM_PQueue changes)
{
    WDM_PQueueItem item;
    LPBYTE current_info_entry_offset;
    PFILE_NOTIFY_INFORMATION info;
    VALUE event;

    WDM_DEBUG("---------------------------");
    WDM_DEBUG("Process changes");
    WDM_DEBUG("--------------------------");

    while( ! wdm_queue_is_empty(changes) ) {
        item = wdm_queue_dequeue(changes);

        if ( item->type == WDM_QUEUE_ITEM_TYPE_ERROR ) {
            rb_raise(item->error->exception_klass, item->error->message);
        }
        else {
            current_info_entry_offset = (LPBYTE)item->data->buffer;

            for(;;) {
                info = (PFILE_NOTIFY_INFORMATION)current_info_entry_offset;
                event = wdm_rb_change_new_from_notification(item->data->user_data->dir, info);

                WDM_DEBUG("---------------------------");
                WDM_DEBUG("Running user callback");
                WDM_DEBUG("--------------------------");

                rb_funcall(item->data->user_data->callback, wdm_rb_sym_call, 1, event);

                WDM_DEBUG("---------------------------");

                if ( ! info->NextEntryOffset ) break;

                current_info_entry_offset += info->NextEntryOffset;
            }
        }

        wdm_queue_item_free(item);
    }
}

static void
stop_monitoring(LPVOID param)
{
    BOOL already_stopped;
    WDM_PMonitor monitor;
    WDM_PEntry entry;

    monitor = (WDM_PMonitor)param;
    already_stopped = FALSE;

    WDM_DEBUG("Stopping the monitor!");

    EnterCriticalSection(&monitor->lock);
        if ( ! monitor->running ) {
            already_stopped = TRUE;
        }
        else {
            monitor->running = FALSE;
        }
    LeaveCriticalSection(&monitor->lock);

    if (already_stopped) {
        WDM_DEBUG("Can't stop monitoring because it's already stopped (or it's never been started)!!");
        return;
    }

    entry = monitor->head;

    while(entry != NULL) {
        CancelIo(entry->dir_handle); // Stop monitoring changes
        entry = entry->next;
    }

    SetEvent(monitor->stop_event);
    SetEvent(monitor->process_event); // The process code checks after the wait for an exit signal
    WaitForSingleObject(monitor->monitoring_thread, 10000);
}

static VALUE
rb_monitor_run_bang(VALUE self)
{
    BOOL already_running,
         waiting_succeeded;
    WDM_PMonitor monitor;

    WDM_DEBUG("Running the monitor!");

    TypedData_Get_Struct(self, WDM_Monitor, &monitor_data_type, monitor);
    already_running = FALSE;

    EnterCriticalSection(&monitor->lock);
        if ( monitor->running ) {
            already_running = TRUE;
        }
        else {
            monitor->running = TRUE;
        }
    LeaveCriticalSection(&monitor->lock);

    if (already_running) {
        WDM_DEBUG("Not doing anything because the monitor is already running!");
        return Qnil;
    }

    // Reset events
    ResetEvent(monitor->process_event);
    ResetEvent(monitor->stop_event);

    monitor->monitoring_thread = CreateThread(
        NULL,                     // default security attributes
        0,                        // use default stack size
        start_monitoring,         // thread function name
        monitor,                  // argument to thread function
        0,                        // use default creation flags
        NULL                      // Ignore thread identifier
    );

    if ( monitor->monitoring_thread == NULL ) {
        rb_raise(eWDM_Error, "Can't create a thread for the monitor!");
    }

    while ( monitor->running ) {

        waiting_succeeded = (VALUE)rb_thread_call_without_gvl(wait_for_changes, monitor->process_event, stop_monitoring, monitor);

        if ( waiting_succeeded == Qfalse ) {
            rb_raise(eWDM_Error, "Failed while waiting for a change in the watched directories!");
        }

        if ( ! monitor->running ) {
            wdm_queue_empty(monitor->changes);
            return Qnil;
        }

        process_changes(monitor->changes);

        if ( ! ResetEvent(monitor->process_event) ) {
            rb_raise(eWDM_Error, "Couldn't reset system events to watch for changes!");
        }
    }

    return Qnil;
}

static VALUE
rb_monitor_stop(VALUE self)
{
    WDM_PMonitor monitor;

    TypedData_Get_Struct(self, WDM_Monitor, &monitor_data_type, monitor);

    stop_monitoring(monitor);

    WDM_DEBUG("Stopped the monitor!");

    return Qnil;
}

void
wdm_rb_monitor_init()
{
    WDM_DEBUG("Registering WDM::Monitor with Ruby!");

    wdm_rb_sym_call = rb_intern("call");
    wdm_rb_sym_files = rb_intern("files");
    wdm_rb_sym_directories = rb_intern("directories");
    wdm_rb_sym_attributes = rb_intern("attributes");
    wdm_rb_sym_size = rb_intern("size");
    wdm_rb_sym_last_write = rb_intern("last_write");
    wdm_rb_sym_last_access = rb_intern("last_access");
    wdm_rb_sym_creation = rb_intern("creation");
    wdm_rb_sym_security = rb_intern("security");
    wdm_rb_sym_default = rb_intern("default");

    eWDM_MonitorRunningError = rb_define_class_under(mWDM, "MonitorRunningError", eWDM_Error);
    eWDM_InvalidDirectoryError = rb_define_class_under(mWDM, "InvalidDirectoryError", eWDM_Error);
    eWDM_UnknownFlagError = rb_define_class_under(mWDM, "UnknownFlagError", eWDM_Error);
    eWDM_UnwatchableDirectoryError = rb_define_class_under(mWDM, "UnwatchableDirectoryError", eWDM_Error);

    cWDM_Monitor = rb_define_class_under(mWDM, "Monitor", rb_cObject);

    rb_define_alloc_func(cWDM_Monitor, rb_monitor_alloc);
    rb_define_method(cWDM_Monitor, "watch", RUBY_METHOD_FUNC(rb_monitor_watch), -1);
    rb_define_method(cWDM_Monitor, "watch_recursively", RUBY_METHOD_FUNC(rb_monitor_watch_recursively), -1);
    rb_define_method(cWDM_Monitor, "run!", RUBY_METHOD_FUNC(rb_monitor_run_bang), 0);
    rb_define_method(cWDM_Monitor, "stop", RUBY_METHOD_FUNC(rb_monitor_stop), 0);
}
