// Copyright © SixtyFPS GmbH <info@slint.dev>
// SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0

// cSpell: ignore nsimage nsmenuitem
//! macOS system tray backend using AppKit (`NSStatusBar`, `NSStatusItem`, `NSMenu`).
//!
//! Everything in this module must run on the main thread. `SystemTrayIconHandle::new` is
//! driven by a `ChangeTracker` that fires from the Slint event loop, which on macOS
//! with the winit backend is the main thread.

use super::{Error, Params};
use crate::SharedVector;
use crate::graphics::Image;
use crate::item_tree::ItemWeak;
use crate::items::{ColorScheme, MenuEntry};
use crate::menus::MenuVTable;

use core::ffi::c_void;

use objc2::rc::Retained;
use objc2::runtime::{AnyObject, Sel};
use objc2::{
    AllocAnyThread, DefinedClass, MainThreadMarker, MainThreadOnly, define_class, msg_send, sel,
};
use objc2_app_kit::{
    NSAppearance, NSAppearanceCustomization, NSAppearanceNameAqua, NSAppearanceNameDarkAqua,
    NSImage, NSMenu, NSMenuItem, NSStatusBar, NSStatusItem, NSVariableStatusItemLength,
};
use objc2_foundation::{
    NSArray, NSData, NSDictionary, NSKeyValueChangeKey, NSKeyValueObservingOptions, NSObject,
    NSObjectNSKeyValueObserverRegistration, NSObjectProtocol, NSSize, NSString, ns_string,
};

// Mirror the other backends' depth cap to protect against accidental infinite menu trees.
const MAX_DEPTH: usize = 15;

// ---------------------------------------------------------------------------
// MenuAction: the ObjC target object attached to every NSMenuItem. The menu
// item's `tag` carries the entry index; the selector upgrades the weak and
// dispatches activation back through the Slint event loop.
// ---------------------------------------------------------------------------

struct MenuActionIvars {
    self_weak: ItemWeak,
}

define_class!(
    #[unsafe(super = NSObject)]
    #[thread_kind = MainThreadOnly]
    #[ivars = MenuActionIvars]
    struct MenuAction;

    unsafe impl NSObjectProtocol for MenuAction {}

    impl MenuAction {
        #[unsafe(method(activated:))]
        fn activated(&self, sender: &NSMenuItem) {
            let entry_index = sender.tag() as usize;
            // AppKit fires menu actions on the main thread, which with the winit
            // backend is the Slint event-loop thread; dispatch directly.
            activate_entry(&self.ivars().self_weak, entry_index);
        }

        // Wired as the status-bar button's action. Fires only when AppKit
        // hasn't auto-popped a menu — i.e. when no `NSMenu` is attached
        // (which `rebuild_menu` toggles based on whether the slint menu
        // currently has any entries).
        #[unsafe(method(tray_clicked:))]
        fn tray_clicked(&self, _sender: *const objc2::runtime::AnyObject) {
            let Some(item_rc) = self.ivars().self_weak.upgrade() else { return };
            let Some(tray) = item_rc.downcast::<super::SystemTrayIcon>() else { return };
            tray.as_pin_ref().clicked.call(&());
        }
    }
);

impl MenuAction {
    fn new(mtm: MainThreadMarker, self_weak: ItemWeak) -> Retained<Self> {
        let this = Self::alloc(mtm).set_ivars(MenuActionIvars { self_weak });
        unsafe { msg_send![super(this), init] }
    }
}

fn activate_entry(self_weak: &ItemWeak, entry_index: usize) {
    let Some(item_rc) = self_weak.upgrade() else { return };
    let Some(tray) = item_rc.downcast::<super::SystemTrayIcon>() else { return };
    let tray = tray.as_pin_ref();
    let menu_borrow = tray.data.menu.borrow();
    let Some(state) = menu_borrow.as_ref() else { return };
    if let Some(entry) = state.entries.get(entry_index) {
        vtable::VRc::borrow(&state.menu_vrc).activate(entry);
    }
}

struct AppearanceObserverIvars {
    self_weak: ItemWeak,
}

define_class!(
    #[unsafe(super = NSObject)]
    #[thread_kind = MainThreadOnly]
    #[ivars = AppearanceObserverIvars]
    struct AppearanceObserver;

    unsafe impl NSObjectProtocol for AppearanceObserver {}

    impl AppearanceObserver {
        #[unsafe(method(observeValueForKeyPath:ofObject:change:context:))]
        fn observe(
            &self,
            _key_path: Option<&NSString>,
            of_object: Option<&AnyObject>,
            _change: Option<&NSDictionary<NSKeyValueChangeKey, AnyObject>>,
            _context: *mut c_void,
        ) {
            let Some(of_object) = of_object else { return };
            let Some(status_item) = of_object.downcast_ref::<NSStatusItem>() else { return };
            let Some(button) = status_item.button(self.mtm()) else { return };
            let scheme = scheme_for_appearance(&button.effectiveAppearance());
            let Some(item_rc) = self.ivars().self_weak.upgrade() else { return };
            let Some(tray) = item_rc.downcast::<super::SystemTrayIcon>() else { return };
            tray.as_pin_ref().set_color_scheme(scheme);
        }
    }
);

impl AppearanceObserver {
    fn new(mtm: MainThreadMarker, self_weak: ItemWeak) -> Retained<Self> {
        let this = Self::alloc(mtm).set_ivars(AppearanceObserverIvars { self_weak });
        unsafe { msg_send![super(this), init] }
    }
}

fn scheme_for_appearance(appearance: &NSAppearance) -> ColorScheme {
    // `bestMatchFromAppearancesWithNames:` collapses the high-contrast / vibrant
    // variants down to whichever of these two best matches, so we don't have to
    // enumerate them. `None` means it didn't match either family — surface that
    // as `Unknown` rather than guessing.
    let names = [unsafe { NSAppearanceNameAqua }, unsafe { NSAppearanceNameDarkAqua }];
    let candidates = NSArray::from_slice(&names);
    let Some(best) = appearance.bestMatchFromAppearancesWithNames(&candidates) else {
        return ColorScheme::Unknown;
    };
    if &*best == unsafe { NSAppearanceNameDarkAqua } {
        ColorScheme::Dark
    } else {
        ColorScheme::Light
    }
}

// ---------------------------------------------------------------------------
// Icon conversion: Slint `Image` -> `NSImage` via `NSBitmapImageRep`.
// ---------------------------------------------------------------------------

fn image_to_nsimage(icon: &Image) -> Result<Retained<NSImage>, Error> {
    let pixel_buffer = icon.to_rgba8().ok_or(Error::Rgba8)?;
    let width = pixel_buffer.width();
    let height = pixel_buffer.height();

    // PNG-encode via the `image` crate, then hand the bytes to NSImage. This keeps
    // the whole conversion inside safe APIs and avoids the pixel format / alpha
    // premultiplication pitfalls of building an NSBitmapImageRep by hand.
    let mut png = std::vec::Vec::<u8>::new();
    <image::codecs::png::PngEncoder<_> as image::ImageEncoder>::write_image(
        image::codecs::png::PngEncoder::new(&mut png),
        pixel_buffer.as_bytes(),
        width,
        height,
        image::ExtendedColorType::Rgba8,
    )
    .map_err(|_| Error::Rgba8)?;

    let data = NSData::with_bytes(&png);
    let image = NSImage::initWithData(NSImage::alloc(), &data).ok_or(Error::Rgba8)?;

    // macOS status bar icons are conventionally 18pt tall, with width scaled to
    // preserve aspect ratio. The PNG representation keeps full resolution for
    // HiDPI; AppKit scales to this point size.
    const TRAY_ICON_HEIGHT_PT: f64 = 18.0;
    let aspect = width as f64 / height as f64;
    image.setSize(NSSize { width: TRAY_ICON_HEIGHT_PT * aspect, height: TRAY_ICON_HEIGHT_PT });
    Ok(image)
}

// ---------------------------------------------------------------------------
// PlatformTray: one per SystemTrayIcon item.
// ---------------------------------------------------------------------------

pub struct PlatformTray {
    status_item: Retained<NSStatusItem>,
    action_target: Retained<MenuAction>,
    appearance_observer: Retained<AppearanceObserver>,
    mtm: MainThreadMarker,
}

impl PlatformTray {
    pub fn new(
        params: Params,
        self_weak: ItemWeak,
        _context: &crate::SlintContext,
    ) -> Result<Self, Error> {
        let mtm = MainThreadMarker::new()
            .expect("SystemTrayIcon must be created on the main thread on macOS");

        let image = image_to_nsimage(params.icon)?;

        let status_bar = NSStatusBar::systemStatusBar();
        let status_item = status_bar.statusItemWithLength(NSVariableStatusItemLength);

        let action_target = MenuAction::new(mtm, self_weak.clone());

        if let Some(button) = status_item.button(mtm) {
            button.setImage(Some(&image));
            let tooltip = NSString::from_str(params.tooltip);
            button.setToolTip(Some(&tooltip));
            // Slint's `title` is the visible text next to the icon (think
            // battery percentage or system clock). Setting an empty string
            // simply leaves no label, which is the natural default.
            let title = NSString::from_str(params.title);
            button.setTitle(&title);
            // Route clicks back to slint's `clicked` callback. NSStatusItem
            // only fires the button's action when no menu is attached;
            // `rebuild_menu` toggles `setMenu` on/off based on whether the
            // slint menu has entries, so a tray with no Menu (or one whose
            // `if cond : Menu` is currently false) routes clicks here, while
            // a tray with menu entries gets the standard auto-popup behavior.
            // NSButton's default `sendActionOn` mask is `LeftMouseUp`, so the
            // callback never fires on a right- or middle-click.
            unsafe {
                button.setTarget(Some(action_target.as_ref()));
                button.setAction(Some(sel!(tray_clicked:)));
            }
        }

        let observer = AppearanceObserver::new(mtm, self_weak);
        unsafe {
            status_item.addObserver_forKeyPath_options_context(
                &observer,
                ns_string!("button.effectiveAppearance"),
                NSKeyValueObservingOptions::Initial | NSKeyValueObservingOptions::New,
                core::ptr::null_mut(),
            );
        }

        Ok(Self { status_item, action_target, appearance_observer: observer, mtm })
    }

    pub fn rebuild_menu(
        &self,
        menu: vtable::VRef<'_, MenuVTable>,
        entries_out: &mut std::vec::Vec<MenuEntry>,
    ) {
        entries_out.clear();
        let ns_menu = build_menu(menu, &self.action_target, self.mtm, entries_out);
        // Detach the menu when there are no entries so AppKit forwards clicks
        // to the button's action (which fires slint's `clicked`). An
        // `if cond : Menu { ... }` whose condition is false reports zero
        // entries here; on the next flip we re-attach a populated NSMenu.
        if entries_out.is_empty() {
            self.status_item.setMenu(None);
        } else {
            self.status_item.setMenu(Some(&ns_menu));
        }
    }

    pub fn set_visible(&self, visible: bool) {
        // NSStatusItem has a real visibility toggle since macOS 10.12, so unlike
        // the SNI/ksni backend we don't need to deregister and respawn.
        self.status_item.setVisible(visible);
    }

    pub fn set_icon(&self, icon: &Image) {
        // Build the new NSImage first; if conversion fails, leave the previous
        // icon in place rather than blanking the button.
        let Ok(image) = image_to_nsimage(icon) else { return };
        if let Some(button) = self.status_item.button(self.mtm) {
            button.setImage(Some(&image));
        }
    }

    pub fn set_tooltip(&self, tooltip: &str) {
        if let Some(button) = self.status_item.button(self.mtm) {
            let s = NSString::from_str(tooltip);
            button.setToolTip(Some(&s));
        }
    }

    pub fn set_title(&self, title: &str) {
        if let Some(button) = self.status_item.button(self.mtm) {
            let s = NSString::from_str(title);
            button.setTitle(&s);
        }
    }
}

impl Drop for PlatformTray {
    fn drop(&mut self) {
        unsafe {
            self.status_item.removeObserver_forKeyPath(
                &self.appearance_observer,
                ns_string!("button.effectiveAppearance"),
            );
        }
        let status_bar = NSStatusBar::systemStatusBar();
        status_bar.removeStatusItem(&self.status_item);
    }
}

// ---------------------------------------------------------------------------
// Menu building.
// ---------------------------------------------------------------------------

fn build_menu(
    menu: vtable::VRef<'_, MenuVTable>,
    target: &MenuAction,
    mtm: MainThreadMarker,
    entries_out: &mut std::vec::Vec<MenuEntry>,
) -> Retained<NSMenu> {
    let ns_menu = NSMenu::new(mtm);
    let mut top = SharedVector::<MenuEntry>::default();
    menu.sub_menu(None, &mut top);
    for entry in top.iter() {
        let item = entry_to_nsmenuitem(menu, entry, 0, target, mtm, entries_out);
        ns_menu.addItem(&item);
    }
    ns_menu
}

fn entry_to_nsmenuitem(
    menu: vtable::VRef<'_, MenuVTable>,
    entry: &MenuEntry,
    depth: usize,
    target: &MenuAction,
    mtm: MainThreadMarker,
    entries_out: &mut std::vec::Vec<MenuEntry>,
) -> Retained<NSMenuItem> {
    if entry.is_separator {
        return NSMenuItem::separatorItem(mtm);
    }

    if entry.has_sub_menu && depth < MAX_DEPTH {
        // Submenu headers aren't clicked directly, so no target/action/tag is needed.
        let title = NSString::from_str(entry.title.as_str());
        let item = NSMenuItem::new(mtm);
        item.setTitle(&title);
        item.setEnabled(entry.enabled);
        let sub_ns_menu = NSMenu::new(mtm);
        sub_ns_menu.setTitle(&title);
        let mut children = SharedVector::<MenuEntry>::default();
        menu.sub_menu(Some(entry), &mut children);
        for child in children.iter() {
            let child_item = entry_to_nsmenuitem(menu, child, depth + 1, target, mtm, entries_out);
            sub_ns_menu.addItem(&child_item);
        }
        item.setSubmenu(Some(&sub_ns_menu));
        item
    } else {
        let entry_index = entries_out.len();
        entries_out.push(entry.clone());

        let title = NSString::from_str(entry.title.as_str());
        let item = NSMenuItem::new(mtm);
        item.setTitle(&title);
        item.setEnabled(entry.enabled);
        item.setTag(entry_index as isize);
        // Tray menu items are never targeted at a focused responder; wire the action
        // to our MenuAction object directly. `shortcut` bindings on MenuItem are
        // ignored (see the SystemTrayIcon docs) so no key equivalent is set.
        unsafe { item.setTarget(Some(target.as_ref())) };
        let action: Sel = sel!(activated:);
        unsafe { item.setAction(Some(action)) };
        item
    }
}
