// Tap to Sibelius - persistent macOS menu bar app.
//
// Same MIDI engine as the original Terminal helper: a virtual CoreMIDI source
// called "Tap to Sibelius" plus a global key tap that turns one computer key
// into one MIDI key. Onset mode: each tap ends the previous note and starts the
// next one, so the gap between taps is the note value. Sibelius decides every
// duration. This app never touches the Sibelius transport.
//
// The only change from the helper is hosting: this is an app bundle that lives
// in the menu bar, survives Terminal being closed, and can launch at login.

import AppKit
import CoreMIDI
import CoreGraphics
import ServiceManagement

// MARK: - Settings

enum Keys {
    static let tapKey = "tapKeyCode"
    static let pitch = "pitch"
    static let startEnabled = "startEnabled"
    static let didFirstRun = "didFirstRun"
}

struct KeyChoice {
    let name: String
    let code: Int64
}

let keyChoices: [KeyChoice] = [
    KeyChoice(name: "K", code: 40),
    KeyChoice(name: "J", code: 38),
    KeyChoice(name: "L", code: 37),
    KeyChoice(name: "Space", code: 49),
    KeyChoice(name: "N", code: 45),
    KeyChoice(name: "M", code: 46),
]

let pitchChoices: [(String, UInt8)] = [
    ("C3", 48), ("C4 (middle C)", 60), ("G4", 67), ("C5", 72),
]

let stopKeyCode: Int64 = 53 // Escape

// MARK: - MIDI engine (unchanged behaviour)

final class MidiEngine {
    private var client = MIDIClientRef()
    private var source = MIDIEndpointRef()
    private var sounding = false
    private(set) var taps = 0

    init() {
        MIDIClientCreate("Tap to Sibelius" as CFString, nil, nil, &client)
        MIDISourceCreate(client, "Tap to Sibelius" as CFString, &source)
    }

    private func send(_ bytes: [UInt8]) {
        var list = MIDIPacketList()
        let packet = MIDIPacketListInit(&list)
        _ = MIDIPacketListAdd(&list, MemoryLayout<MIDIPacketList>.size, packet, 0, bytes.count, bytes)
        MIDIReceived(source, &list)
    }

    func noteOff(_ pitch: UInt8) {
        if sounding {
            send([0x80, pitch, 0])
            sounding = false
        }
    }

    func tapOnset(pitch: UInt8, velocity: UInt8 = 100) {
        noteOff(pitch)
        send([0x90, pitch, velocity])
        sounding = true
        taps += 1
    }

    func allNotesOff(pitch: UInt8) {
        noteOff(pitch)
        send([0xB0, 123, 0])
    }

    func dispose(pitch: UInt8) {
        allNotesOff(pitch: pitch)
        MIDIEndpointDispose(source)
        MIDIClientDispose(client)
    }
}

// MARK: - App

final class AppController: NSObject, NSApplicationDelegate {
    private let midi = MidiEngine()
    private let statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
    private var eventTap: CFMachPort?
    private var runLoopSource: CFRunLoopSource?
    private var permissionTimer: Timer?

    private var enabled = false

    private var tapKeyCode: Int64 {
        let stored = UserDefaults.standard.object(forKey: Keys.tapKey) as? Int
        return Int64(stored ?? 40)
    }

    private var pitch: UInt8 {
        let stored = UserDefaults.standard.object(forKey: Keys.pitch) as? Int
        return UInt8(stored ?? 60)
    }

    private var tapKeyName: String {
        keyChoices.first { $0.code == tapKeyCode }?.name ?? "K"
    }

    func applicationDidFinishLaunching(_ notification: Notification) {
        statusItem.button?.title = "♪"
        statusItem.button?.toolTip = "Tap to Sibelius"
        rebuildMenu()

        let startEnabled = UserDefaults.standard.object(forKey: Keys.startEnabled) as? Bool ?? true
        if startEnabled { setEnabled(true) }
        if !accessibilityGranted() { showAccessibilityPrompt() }

        // First run: switch on Launch at login so it is always ready when Sibelius opens.
        if !UserDefaults.standard.bool(forKey: Keys.didFirstRun) {
            UserDefaults.standard.set(true, forKey: Keys.didFirstRun)
            if #available(macOS 13.0, *) {
                if SMAppService.mainApp.status != .enabled {
                    try? SMAppService.mainApp.register()
                }
            }
            rebuildMenu()
        }

        NSWorkspace.shared.notificationCenter.addObserver(
            self, selector: #selector(didWake),
            name: NSWorkspace.didWakeNotification, object: nil)
    }

    func applicationWillTerminate(_ notification: Notification) {
        stopKeyboard()
        midi.dispose(pitch: pitch)
    }

    // MARK: Accessibility

    private func accessibilityGranted() -> Bool {
        AXIsProcessTrusted()
    }

    private func showAccessibilityPrompt() {
        let alert = NSAlert()
        alert.messageText = "Allow keyboard access"
        alert.informativeText = """
        Tap to Sibelius needs Accessibility permission so your Mac keyboard can act as a MIDI keyboard in Sibelius.

        Turn on Tap to Sibelius in the list, then come back here. Nothing else is needed.
        """
        alert.addButton(withTitle: "Open Accessibility settings")
        alert.addButton(withTitle: "Later")
        if alert.runModal() == .alertFirstButtonReturn {
            openAccessibilitySettings()
        }
        watchForPermission()
    }

    private func openAccessibilitySettings() {
        let options = [kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String: true]
        _ = AXIsProcessTrustedWithOptions(options as CFDictionary)
        if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility") {
            NSWorkspace.shared.open(url)
        }
    }

    private func watchForPermission() {
        permissionTimer?.invalidate()
        permissionTimer = Timer.scheduledTimer(withTimeInterval: 2, repeats: true) { [weak self] timer in
            guard let self else { return }
            if self.accessibilityGranted() {
                timer.invalidate()
                self.permissionTimer = nil
                if self.enabled { self.startKeyboard() }
                self.rebuildMenu()
            }
        }
    }

    // MARK: Keyboard capture

    private func startKeyboard() {
        guard eventTap == nil, accessibilityGranted() else { return }
        let mask = (1 << CGEventType.keyDown.rawValue) | (1 << CGEventType.keyUp.rawValue)
        let callback: CGEventTapCallBack = { _, type, event, refcon in
            guard let refcon else { return Unmanaged.passUnretained(event) }
            let controller = Unmanaged<AppController>.fromOpaque(refcon).takeUnretainedValue()
            return controller.handle(type: type, event: event)
        }
        guard let tap = CGEvent.tapCreate(
            tap: .cgSessionEventTap,
            place: .headInsertEventTap,
            options: .defaultTap,
            eventsOfInterest: CGEventMask(mask),
            callback: callback,
            userInfo: Unmanaged.passUnretained(self).toOpaque()
        ) else { return }
        eventTap = tap
        runLoopSource = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, tap, 0)
        if let source = runLoopSource {
            CFRunLoopAddSource(CFRunLoopGetCurrent(), source, .commonModes)
        }
        CGEvent.tapEnable(tap: tap, enable: true)
    }

    private func stopKeyboard() {
        if let tap = eventTap { CGEvent.tapEnable(tap: tap, enable: false) }
        if let source = runLoopSource {
            CFRunLoopRemoveSource(CFRunLoopGetCurrent(), source, .commonModes)
        }
        runLoopSource = nil
        eventTap = nil
        midi.allNotesOff(pitch: pitch)
    }

    fileprivate func handle(type: CGEventType, event: CGEvent) -> Unmanaged<CGEvent>? {
        if type == .tapDisabledByTimeout || type == .tapDisabledByUserInput {
            if let tap = eventTap { CGEvent.tapEnable(tap: tap, enable: true) }
            return Unmanaged.passUnretained(event)
        }

        guard enabled else { return Unmanaged.passUnretained(event) }

        let flags = event.flags
        if flags.contains(.maskCommand) || flags.contains(.maskControl) || flags.contains(.maskAlternate) {
            return Unmanaged.passUnretained(event)
        }

        let code = event.getIntegerValueField(.keyboardEventKeycode)

        if code == stopKeyCode && type == .keyDown {
            midi.allNotesOff(pitch: pitch)
            return Unmanaged.passUnretained(event)
        }

        if code == tapKeyCode {
            if type == .keyDown && event.getIntegerValueField(.keyboardEventAutorepeat) == 0 {
                midi.tapOnset(pitch: pitch)
                DispatchQueue.main.async { [weak self] in self?.rebuildMenu() }
            }
            return nil // never let the letter reach Sibelius while tap mode is on
        }

        return Unmanaged.passUnretained(event)
    }

    @objc private func didWake() {
        guard enabled else { return }
        stopKeyboard()
        startKeyboard()
    }

    // MARK: State

    private func setEnabled(_ value: Bool) {
        enabled = value
        if value {
            startKeyboard()
            if !accessibilityGranted() { watchForPermission() }
        } else {
            stopKeyboard()
        }
        statusItem.button?.title = value ? "♪" : "♪̸"
        rebuildMenu()
    }

    // MARK: Menu

    private func rebuildMenu() {
        let menu = NSMenu()

        let ready = enabled && accessibilityGranted()
        let statusText: String
        if !accessibilityGranted() {
            statusText = "○ Needs keyboard permission"
        } else {
            statusText = ready ? "● Tap mode on" : "○ Tap mode off"
        }
        let status = NSMenuItem(title: statusText, action: nil, keyEquivalent: "")
        status.isEnabled = false
        menu.addItem(status)

        let toggle = NSMenuItem(
            title: enabled ? "Turn tap mode off" : "Turn tap mode on",
            action: #selector(toggleEnabled), keyEquivalent: "")
        toggle.target = self
        menu.addItem(toggle)

        if !accessibilityGranted() {
            let perm = NSMenuItem(
                title: "Allow keyboard access...", action: #selector(permissionClicked),
                keyEquivalent: "")
            perm.target = self
            menu.addItem(perm)
        }

        menu.addItem(NSMenuItem.separator())

        let keyItem = NSMenuItem(title: "Tap key: \(tapKeyName)", action: nil, keyEquivalent: "")
        let keyMenu = NSMenu()
        for choice in keyChoices {
            let item = NSMenuItem(title: choice.name, action: #selector(pickKey(_:)), keyEquivalent: "")
            item.target = self
            item.tag = Int(choice.code)
            item.state = choice.code == tapKeyCode ? .on : .off
            keyMenu.addItem(item)
        }
        keyItem.submenu = keyMenu
        menu.addItem(keyItem)

        let pitchName = pitchChoices.first { $0.1 == pitch }?.0 ?? "C4 (middle C)"
        let pitchItem = NSMenuItem(title: "Note sent: \(pitchName)", action: nil, keyEquivalent: "")
        let pitchMenu = NSMenu()
        for choice in pitchChoices {
            let item = NSMenuItem(title: choice.0, action: #selector(pickPitch(_:)), keyEquivalent: "")
            item.target = self
            item.tag = Int(choice.1)
            item.state = choice.1 == pitch ? .on : .off
            pitchMenu.addItem(item)
        }
        pitchItem.submenu = pitchMenu
        menu.addItem(pitchItem)

        let login = NSMenuItem(title: "Launch at login", action: #selector(toggleLogin), keyEquivalent: "")
        login.target = self
        login.state = loginEnabled() ? .on : .off
        menu.addItem(login)

        let startOn = NSMenuItem(
            title: "Start with tap mode on", action: #selector(toggleStartEnabled), keyEquivalent: "")
        startOn.target = self
        startOn.state = (UserDefaults.standard.object(forKey: Keys.startEnabled) as? Bool ?? true) ? .on : .off
        menu.addItem(startOn)

        menu.addItem(NSMenuItem.separator())

        let taps = NSMenuItem(title: "Taps sent: \(midi.taps)", action: nil, keyEquivalent: "")
        taps.isEnabled = false
        menu.addItem(taps)

        let help = NSMenuItem(
            title: "In Sibelius: Preferences, Input Devices, Tap to Sibelius",
            action: nil, keyEquivalent: "")
        help.isEnabled = false
        menu.addItem(help)

        menu.addItem(NSMenuItem.separator())
        let quit = NSMenuItem(title: "Quit Tap to Sibelius", action: #selector(quit), keyEquivalent: "q")
        quit.target = self
        menu.addItem(quit)

        statusItem.menu = menu
    }

    @objc private func toggleEnabled() { setEnabled(!enabled) }

    @objc private func permissionClicked() { showAccessibilityPrompt() }

    @objc private func pickKey(_ sender: NSMenuItem) {
        midi.allNotesOff(pitch: pitch)
        UserDefaults.standard.set(sender.tag, forKey: Keys.tapKey)
        rebuildMenu()
    }

    @objc private func pickPitch(_ sender: NSMenuItem) {
        midi.allNotesOff(pitch: pitch)
        UserDefaults.standard.set(sender.tag, forKey: Keys.pitch)
        rebuildMenu()
    }

    @objc private func toggleStartEnabled() {
        let current = UserDefaults.standard.object(forKey: Keys.startEnabled) as? Bool ?? true
        UserDefaults.standard.set(!current, forKey: Keys.startEnabled)
        rebuildMenu()
    }

    // MARK: Launch at login (SMAppService, macOS 13+)

    private func loginEnabled() -> Bool {
        if #available(macOS 13.0, *) {
            return SMAppService.mainApp.status == .enabled
        }
        return false
    }

    @objc private func toggleLogin() {
        if #available(macOS 13.0, *) {
            do {
                if SMAppService.mainApp.status == .enabled {
                    try SMAppService.mainApp.unregister()
                } else {
                    try SMAppService.mainApp.register()
                }
            } catch {
                let alert = NSAlert()
                alert.messageText = "Could not change the login setting"
                alert.informativeText = error.localizedDescription
                alert.runModal()
            }
        } else {
            let alert = NSAlert()
            alert.messageText = "Launch at login needs macOS 13 or later"
            alert.informativeText = "Add Tap to Sibelius under System Settings, General, Login Items."
            alert.runModal()
        }
        rebuildMenu()
    }

    @objc private func quit() { NSApp.terminate(nil) }
}

let app = NSApplication.shared
let controller = AppController()
app.delegate = controller
app.setActivationPolicy(.accessory) // menu bar only, no Dock icon, no window
app.run()
