use std::{
    f64,
    os::raw::c_void,
    sync::{Arc, Weak},
};

use cocoa::{
    appkit::{self, NSApplicationPresentationOptions, NSView, NSWindow, NSWindowOcclusionState},
    base::{id, nil},
    foundation::NSUInteger,
};
use objc::{
    declare::ClassDecl,
    rc::autoreleasepool,
    runtime::{Class, Object, Sel, BOOL, NO, YES},
};
use once_cell::sync::Lazy;

use crate::{
    dpi::{LogicalPosition, LogicalSize},
    event::{Event, ModifiersState, WindowEvent},
    platform_impl::platform::{
        app_state::AppState,
        event::{EventProxy, EventWrapper},
        util::{self, IdRef},
        view::ViewState,
        window::{get_window_id, UnownedWindow},
    },
    window::{Fullscreen, WindowId},
};

pub struct WindowDelegateState {
    ns_window: IdRef, // never changes
    ns_view: IdRef,   // never changes

    window: Weak<UnownedWindow>,

    // TODO: It's possible for delegate methods to be called asynchronously,
    // causing data races / `RefCell` panics.

    // This is set when WindowBuilder::with_fullscreen was set,
    // see comments of `window_did_fail_to_enter_fullscreen`
    initial_fullscreen: bool,

    // During `windowDidResize`, we use this to only send Moved if the position changed.
    previous_position: Option<(f64, f64)>,

    // Used to prevent redundant events.
    previous_scale_factor: f64,
}

impl WindowDelegateState {
    pub fn new(window: &Arc<UnownedWindow>, initial_fullscreen: bool) -> Self {
        let scale_factor = window.scale_factor();
        let mut delegate_state = WindowDelegateState {
            ns_window: window.ns_window.clone(),
            ns_view: window.ns_view.clone(),
            window: Arc::downgrade(window),
            initial_fullscreen,
            previous_position: None,
            previous_scale_factor: scale_factor,
        };

        if scale_factor != 1.0 {
            delegate_state.emit_static_scale_factor_changed_event();
        }

        delegate_state
    }

    fn with_window<F, T>(&mut self, callback: F) -> Option<T>
    where
        F: FnOnce(&UnownedWindow) -> T,
    {
        self.window.upgrade().map(|ref window| callback(window))
    }

    pub fn emit_event(&mut self, event: WindowEvent<'static>) {
        let event = Event::WindowEvent {
            window_id: WindowId(get_window_id(*self.ns_window)),
            event,
        };
        AppState::queue_event(EventWrapper::StaticEvent(event));
    }

    pub fn emit_static_scale_factor_changed_event(&mut self) {
        let scale_factor = self.get_scale_factor();
        if scale_factor == self.previous_scale_factor {
            return;
        };

        self.previous_scale_factor = scale_factor;
        let wrapper = EventWrapper::EventProxy(EventProxy::DpiChangedProxy {
            ns_window: IdRef::retain(*self.ns_window),
            suggested_size: self.view_size(),
            scale_factor,
        });
        AppState::queue_event(wrapper);
    }

    fn emit_move_event(&mut self) {
        let rect = unsafe { NSWindow::frame(*self.ns_window) };
        let x = rect.origin.x as f64;
        let y = util::bottom_left_to_top_left(rect);
        let moved = self.previous_position != Some((x, y));
        if moved {
            self.previous_position = Some((x, y));
            let scale_factor = self.get_scale_factor();
            let physical_pos = LogicalPosition::<f64>::from((x, y)).to_physical(scale_factor);
            self.emit_event(WindowEvent::Moved(physical_pos));
        }
    }

    fn get_scale_factor(&self) -> f64 {
        (unsafe { NSWindow::backingScaleFactor(*self.ns_window) }) as f64
    }

    fn view_size(&self) -> LogicalSize<f64> {
        let ns_size = unsafe { NSView::frame(*self.ns_view).size };
        LogicalSize::new(ns_size.width as f64, ns_size.height as f64)
    }
}

pub fn new_delegate(window: &Arc<UnownedWindow>, initial_fullscreen: bool) -> IdRef {
    let state = WindowDelegateState::new(window, initial_fullscreen);
    unsafe {
        // This is free'd in `dealloc`
        let state_ptr = Box::into_raw(Box::new(state)) as *mut c_void;
        let delegate: id = msg_send![WINDOW_DELEGATE_CLASS.0, alloc];
        IdRef::new(msg_send![delegate, initWithWinit: state_ptr])
    }
}

struct WindowDelegateClass(*const Class);
unsafe impl Send for WindowDelegateClass {}
unsafe impl Sync for WindowDelegateClass {}

static WINDOW_DELEGATE_CLASS: Lazy<WindowDelegateClass> = Lazy::new(|| unsafe {
    let superclass = class!(NSResponder);
    let mut decl = ClassDecl::new("WinitWindowDelegate", superclass).unwrap();

    decl.add_method(sel!(dealloc), dealloc as extern "C" fn(&Object, Sel));
    decl.add_method(
        sel!(initWithWinit:),
        init_with_winit as extern "C" fn(&Object, Sel, *mut c_void) -> id,
    );

    decl.add_method(
        sel!(windowShouldClose:),
        window_should_close as extern "C" fn(&Object, Sel, id) -> BOOL,
    );
    decl.add_method(
        sel!(windowWillClose:),
        window_will_close as extern "C" fn(&Object, Sel, id),
    );
    decl.add_method(
        sel!(windowDidResize:),
        window_did_resize as extern "C" fn(&Object, Sel, id),
    );
    decl.add_method(
        sel!(windowDidMove:),
        window_did_move as extern "C" fn(&Object, Sel, id),
    );
    decl.add_method(
        sel!(windowDidChangeBackingProperties:),
        window_did_change_backing_properties as extern "C" fn(&Object, Sel, id),
    );
    decl.add_method(
        sel!(windowDidBecomeKey:),
        window_did_become_key as extern "C" fn(&Object, Sel, id),
    );
    decl.add_method(
        sel!(windowDidResignKey:),
        window_did_resign_key as extern "C" fn(&Object, Sel, id),
    );

    decl.add_method(
        sel!(draggingEntered:),
        dragging_entered as extern "C" fn(&Object, Sel, id) -> BOOL,
    );
    decl.add_method(
        sel!(prepareForDragOperation:),
        prepare_for_drag_operation as extern "C" fn(&Object, Sel, id) -> BOOL,
    );
    decl.add_method(
        sel!(performDragOperation:),
        perform_drag_operation as extern "C" fn(&Object, Sel, id) -> BOOL,
    );
    decl.add_method(
        sel!(concludeDragOperation:),
        conclude_drag_operation as extern "C" fn(&Object, Sel, id),
    );
    decl.add_method(
        sel!(draggingExited:),
        dragging_exited as extern "C" fn(&Object, Sel, id),
    );

    decl.add_method(
        sel!(window:willUseFullScreenPresentationOptions:),
        window_will_use_fullscreen_presentation_options
            as extern "C" fn(&Object, Sel, id, NSUInteger) -> NSUInteger,
    );
    decl.add_method(
        sel!(windowDidEnterFullScreen:),
        window_did_enter_fullscreen as extern "C" fn(&Object, Sel, id),
    );
    decl.add_method(
        sel!(windowWillEnterFullScreen:),
        window_will_enter_fullscreen as extern "C" fn(&Object, Sel, id),
    );
    decl.add_method(
        sel!(windowDidExitFullScreen:),
        window_did_exit_fullscreen as extern "C" fn(&Object, Sel, id),
    );
    decl.add_method(
        sel!(windowWillExitFullScreen:),
        window_will_exit_fullscreen as extern "C" fn(&Object, Sel, id),
    );
    decl.add_method(
        sel!(windowDidFailToEnterFullScreen:),
        window_did_fail_to_enter_fullscreen as extern "C" fn(&Object, Sel, id),
    );
    decl.add_method(
        sel!(windowDidChangeOcclusionState:),
        window_did_change_occlusion_state as extern "C" fn(&Object, Sel, id),
    );

    decl.add_ivar::<*mut c_void>("winitState");
    WindowDelegateClass(decl.register())
});

// This function is definitely unsafe, but labeling that would increase
// boilerplate and wouldn't really clarify anything...
fn with_state<F: FnOnce(&mut WindowDelegateState) -> T, T>(this: &Object, callback: F) {
    let state_ptr = unsafe {
        let state_ptr: *mut c_void = *this.get_ivar("winitState");
        &mut *(state_ptr as *mut WindowDelegateState)
    };
    callback(state_ptr);
}

extern "C" fn dealloc(this: &Object, _sel: Sel) {
    with_state(this, |state| unsafe {
        drop(Box::from_raw(state as *mut WindowDelegateState));
    });
}

extern "C" fn init_with_winit(this: &Object, _sel: Sel, state: *mut c_void) -> id {
    unsafe {
        let this: id = msg_send![this, init];
        if this != nil {
            (*this).set_ivar("winitState", state);
            with_state(&*this, |state| {
                let _: () = msg_send![*state.ns_window, setDelegate: this];
            });
        }
        this
    }
}

extern "C" fn window_should_close(this: &Object, _: Sel, _: id) -> BOOL {
    trace_scope!("windowShouldClose:");
    with_state(this, |state| state.emit_event(WindowEvent::CloseRequested));
    NO
}

extern "C" fn window_will_close(this: &Object, _: Sel, _: id) {
    trace_scope!("windowWillClose:");
    with_state(this, |state| unsafe {
        // `setDelegate:` retains the previous value and then autoreleases it
        autoreleasepool(|| {
            // Since El Capitan, we need to be careful that delegate methods can't
            // be called after the window closes.
            let _: () = msg_send![*state.ns_window, setDelegate: nil];
        });
        state.emit_event(WindowEvent::Destroyed);
    });
}

extern "C" fn window_did_resize(this: &Object, _: Sel, _: id) {
    trace_scope!("windowDidResize:");
    with_state(this, |state| {
        // NOTE: WindowEvent::Resized is reported in frameDidChange.
        state.emit_move_event();
    });
}

// This won't be triggered if the move was part of a resize.
extern "C" fn window_did_move(this: &Object, _: Sel, _: id) {
    trace_scope!("windowDidMove:");
    with_state(this, |state| {
        state.emit_move_event();
    });
}

extern "C" fn window_did_change_backing_properties(this: &Object, _: Sel, _: id) {
    trace_scope!("windowDidChangeBackingProperties:");
    with_state(this, |state| {
        state.emit_static_scale_factor_changed_event();
    });
}

extern "C" fn window_did_become_key(this: &Object, _: Sel, _: id) {
    trace_scope!("windowDidBecomeKey:");
    with_state(this, |state| {
        // TODO: center the cursor if the window had mouse grab when it
        // lost focus
        state.emit_event(WindowEvent::Focused(true));
    });
}

extern "C" fn window_did_resign_key(this: &Object, _: Sel, _: id) {
    trace_scope!("windowDidResignKey:");
    with_state(this, |state| {
        // It happens rather often, e.g. when the user is Cmd+Tabbing, that the
        // NSWindowDelegate will receive a didResignKey event despite no event
        // being received when the modifiers are released.  This is because
        // flagsChanged events are received by the NSView instead of the
        // NSWindowDelegate, and as a result a tracked modifiers state can quite
        // easily fall out of synchrony with reality.  This requires us to emit
        // a synthetic ModifiersChanged event when we lose focus.
        //
        // Here we (very unsafely) acquire the winitState (a ViewState) from the
        // Object referenced by state.ns_view (an IdRef, which is dereferenced
        // to an id)
        let view_state: &mut ViewState = unsafe {
            let ns_view: &Object = (*state.ns_view).as_ref().expect("failed to deref");
            let state_ptr: *mut c_void = *ns_view.get_ivar("winitState");
            &mut *(state_ptr as *mut ViewState)
        };

        // Both update the state and emit a ModifiersChanged event.
        if !view_state.modifiers.is_empty() {
            view_state.modifiers = ModifiersState::empty();
            state.emit_event(WindowEvent::ModifiersChanged(view_state.modifiers));
        }

        state.emit_event(WindowEvent::Focused(false));
    });
}

/// Invoked when the dragged image enters destination bounds or frame
extern "C" fn dragging_entered(this: &Object, _: Sel, sender: id) -> BOOL {
    trace_scope!("draggingEntered:");

    use cocoa::{appkit::NSPasteboard, foundation::NSFastEnumeration};
    use std::path::PathBuf;

    let pb: id = unsafe { msg_send![sender, draggingPasteboard] };
    let filenames = unsafe { NSPasteboard::propertyListForType(pb, appkit::NSFilenamesPboardType) };

    for file in unsafe { filenames.iter() } {
        use cocoa::foundation::NSString;
        use std::ffi::CStr;

        unsafe {
            let f = NSString::UTF8String(file);
            let path = CStr::from_ptr(f).to_string_lossy().into_owned();

            with_state(this, |state| {
                state.emit_event(WindowEvent::HoveredFile(PathBuf::from(path)));
            });
        }
    }

    YES
}

/// Invoked when the image is released
extern "C" fn prepare_for_drag_operation(_: &Object, _: Sel, _: id) -> BOOL {
    trace_scope!("prepareForDragOperation:");
    YES
}

/// Invoked after the released image has been removed from the screen
extern "C" fn perform_drag_operation(this: &Object, _: Sel, sender: id) -> BOOL {
    trace_scope!("performDragOperation:");

    use cocoa::{appkit::NSPasteboard, foundation::NSFastEnumeration};
    use std::path::PathBuf;

    let pb: id = unsafe { msg_send![sender, draggingPasteboard] };
    let filenames = unsafe { NSPasteboard::propertyListForType(pb, appkit::NSFilenamesPboardType) };

    for file in unsafe { filenames.iter() } {
        use cocoa::foundation::NSString;
        use std::ffi::CStr;

        unsafe {
            let f = NSString::UTF8String(file);
            let path = CStr::from_ptr(f).to_string_lossy().into_owned();

            with_state(this, |state| {
                state.emit_event(WindowEvent::DroppedFile(PathBuf::from(path)));
            });
        }
    }

    YES
}

/// Invoked when the dragging operation is complete
extern "C" fn conclude_drag_operation(_: &Object, _: Sel, _: id) {
    trace_scope!("concludeDragOperation:");
}

/// Invoked when the dragging operation is cancelled
extern "C" fn dragging_exited(this: &Object, _: Sel, _: id) {
    trace_scope!("draggingExited:");
    with_state(this, |state| {
        state.emit_event(WindowEvent::HoveredFileCancelled)
    });
}

/// Invoked when before enter fullscreen
extern "C" fn window_will_enter_fullscreen(this: &Object, _: Sel, _: id) {
    trace_scope!("windowWillEnterFullscreen:");

    with_state(this, |state| {
        state.with_window(|window| {
            let mut shared_state = window.lock_shared_state("window_will_enter_fullscreen");
            shared_state.maximized = window.is_zoomed();
            let fullscreen = shared_state.fullscreen.as_ref();
            match fullscreen {
                // Exclusive mode sets the state in `set_fullscreen` as the user
                // can't enter exclusive mode by other means (like the
                // fullscreen button on the window decorations)
                Some(Fullscreen::Exclusive(_)) => (),
                // `window_will_enter_fullscreen` was triggered and we're already
                // in fullscreen, so we must've reached here by `set_fullscreen`
                // as it updates the state
                Some(Fullscreen::Borderless(_)) => (),
                // Otherwise, we must've reached fullscreen by the user clicking
                // on the green fullscreen button. Update state!
                None => {
                    let current_monitor = Some(window.current_monitor_inner());
                    shared_state.fullscreen = Some(Fullscreen::Borderless(current_monitor))
                }
            }
            shared_state.in_fullscreen_transition = true;
        })
    });
}

/// Invoked when before exit fullscreen
extern "C" fn window_will_exit_fullscreen(this: &Object, _: Sel, _: id) {
    trace_scope!("windowWillExitFullScreen:");

    with_state(this, |state| {
        state.with_window(|window| {
            let mut shared_state = window.lock_shared_state("window_will_exit_fullscreen");
            shared_state.in_fullscreen_transition = true;
        });
    });
}

extern "C" fn window_will_use_fullscreen_presentation_options(
    this: &Object,
    _: Sel,
    _: id,
    proposed_options: NSUInteger,
) -> NSUInteger {
    trace_scope!("window:willUseFullScreenPresentationOptions:");
    // Generally, games will want to disable the menu bar and the dock. Ideally,
    // this would be configurable by the user. Unfortunately because of our
    // `CGShieldingWindowLevel() + 1` hack (see `set_fullscreen`), our window is
    // placed on top of the menu bar in exclusive fullscreen mode. This looks
    // broken so we always disable the menu bar in exclusive fullscreen. We may
    // still want to make this configurable for borderless fullscreen. Right now
    // we don't, for consistency. If we do, it should be documented that the
    // user-provided options are ignored in exclusive fullscreen.
    let mut options: NSUInteger = proposed_options;
    with_state(this, |state| {
        state.with_window(|window| {
            let shared_state =
                window.lock_shared_state("window_will_use_fullscreen_presentation_options");
            if let Some(Fullscreen::Exclusive(_)) = shared_state.fullscreen {
                options = (NSApplicationPresentationOptions::NSApplicationPresentationFullScreen
                    | NSApplicationPresentationOptions::NSApplicationPresentationHideDock
                    | NSApplicationPresentationOptions::NSApplicationPresentationHideMenuBar)
                    .bits();
            }
        })
    });

    options
}

/// Invoked when entered fullscreen
extern "C" fn window_did_enter_fullscreen(this: &Object, _: Sel, _: id) {
    trace_scope!("windowDidEnterFullscreen:");
    with_state(this, |state| {
        state.initial_fullscreen = false;
        state.with_window(|window| {
            let mut shared_state = window.lock_shared_state("window_did_enter_fullscreen");
            shared_state.in_fullscreen_transition = false;
            let target_fullscreen = shared_state.target_fullscreen.take();
            drop(shared_state);
            if let Some(target_fullscreen) = target_fullscreen {
                window.set_fullscreen(target_fullscreen);
            }
        });
    });
}

/// Invoked when exited fullscreen
extern "C" fn window_did_exit_fullscreen(this: &Object, _: Sel, _: id) {
    trace_scope!("windowDidExitFullscreen:");

    with_state(this, |state| {
        state.with_window(|window| {
            window.restore_state_from_fullscreen();
            let mut shared_state = window.lock_shared_state("window_did_exit_fullscreen");
            shared_state.in_fullscreen_transition = false;
            let target_fullscreen = shared_state.target_fullscreen.take();
            drop(shared_state);
            if let Some(target_fullscreen) = target_fullscreen {
                window.set_fullscreen(target_fullscreen);
            }
        })
    });
}

/// Invoked when fail to enter fullscreen
///
/// When this window launch from a fullscreen app (e.g. launch from VS Code
/// terminal), it creates a new virtual destkop and a transition animation.
/// This animation takes one second and cannot be disable without
/// elevated privileges. In this animation time, all toggleFullscreen events
/// will be failed. In this implementation, we will try again by using
/// performSelector:withObject:afterDelay: until window_did_enter_fullscreen.
/// It should be fine as we only do this at initialzation (i.e with_fullscreen
/// was set).
///
/// From Apple doc:
/// In some cases, the transition to enter full-screen mode can fail,
/// due to being in the midst of handling some other animation or user gesture.
/// This method indicates that there was an error, and you should clean up any
/// work you may have done to prepare to enter full-screen mode.
extern "C" fn window_did_fail_to_enter_fullscreen(this: &Object, _: Sel, _: id) {
    trace_scope!("windowDidFailToEnterFullscreen:");
    with_state(this, |state| {
        state.with_window(|window| {
            let mut shared_state = window.lock_shared_state("window_did_fail_to_enter_fullscreen");
            shared_state.in_fullscreen_transition = false;
            shared_state.target_fullscreen = None;
        });
        if state.initial_fullscreen {
            unsafe {
                let _: () = msg_send![*state.ns_window,
                    performSelector:sel!(toggleFullScreen:)
                    withObject:nil
                    afterDelay: 0.5
                ];
            };
        } else {
            state.with_window(|window| window.restore_state_from_fullscreen());
        }
    });
}

// Invoked when the occlusion state of the window changes
extern "C" fn window_did_change_occlusion_state(this: &Object, _: Sel, _: id) {
    trace_scope!("windowDidChangeOcclusionState:");
    unsafe {
        with_state(this, |state| {
            state.emit_event(WindowEvent::Occluded(
                !state
                    .ns_window
                    .occlusionState()
                    .contains(NSWindowOcclusionState::NSWindowOcclusionStateVisible),
            ))
        });
    }
}
