import AppKit
import VoiceCatCore

// MARK: - Channel tree node

private final class ChannelNode: NSObject {
    let channel: Channel
    var children: [ChannelNode]
    init(_ channel: Channel, children: [ChannelNode] = []) {
        self.channel = channel
        self.children = children
    }
}

// MARK: - Window controller

final class MainWindowController: NSWindowController, NSWindowDelegate {

    // MARK: - Owned client
    private let client: VoiceCatClient
    private let selfUserId: UInt32
    private let nickname: String

    // MARK: - State
    private var currentChannelId: UInt32 = 0
    private var channels: [Channel] = []
    private var users: [UInt32: User] = [:]
    private var talkingUsers: Set<UInt32> = []
    private var ownPermissions = Permissions(canCreateTempChannel: false, canKick: false, canBan: false,
                                             canMoveUsers: false, canAdminAccounts: false, isAdmin: false)
    internal var micStreamId: UInt32 = 0
    private var screenStreamId: UInt32 = 0
    private var screenCapture: ScreenAudioCapture?
    private var auxStreamId: UInt32 = 0          // 0 = aux (second input device) stream not active
    private var auxCapture: InputDeviceCapture?  // client-side capture feeding the aux stream
    // Last app/exclusion choice from the share picker; reused as the default next time.
    private var screenAudioSelection: ScreenAudioSelection = .default
    internal var pttKeyCode: UInt16 = 0x60 {  // F8
        didSet { UserDefaults.standard.set(Int(pttKeyCode), forKey: AudioDefaults.pttKeyCode) }
    }
    private var pttMonitor: Any?
    private var pttEngaged = false  // guards the PTT cue against key-repeat
    private var serverMuted = false
    private var serverDeafened = false
    private var channelTree: [ChannelNode] = []
    private var displayedUsers: [User] = []
    private var displayedAccounts: [Account] = []
    private var adminMenuItem: NSMenuItem?
    private var voiceMenuItem: NSMenuItem?
    private var messagesMenuItem: NSMenuItem?
    private var settingsMenuItem: NSMenuItem?
    private var pmWindows: [UInt32: PrivateMessageWindowController] = [:]
    private var settingsWindowController: SettingsWindowController?

    // MARK: - Audio settings state (source of truth — read/written by SettingsWindowController)
    // The input mode / VAD threshold / mic gain / PTT key persist via UserDefaults (didSet below)
    // so they survive relaunch; loadPersistedAudioSettings() restores them at startup and they are
    // pushed into the core when the mic stream starts (micToggleClicked).

    enum AudioDefaults {
        static let inputMode    = "voice.inputMode"
        static let vadThreshold = "voice.vadThreshold"
        static let inputGain    = "voice.inputGain"
        static let inputNoiseReduction = "voice.inputNoiseReduction"
        static let stereoMic    = "voice.stereoMic"
        static let pttKeyCode   = "voice.pttKeyCode"
        static let auxEnabled   = "voice.auxEnabled"
        static let auxDeviceUID = "voice.auxDeviceUID"
        static let auxGain      = "voice.auxGain"
    }

    internal var selectedInputMode: VoiceCatInputMode = .voiceActivation {
        didSet { UserDefaults.standard.set(Int(selectedInputMode.rawValue), forKey: AudioDefaults.inputMode) }
    }
    internal var vadThresholdValue: Float = 0.05 {
        didSet { UserDefaults.standard.set(vadThresholdValue, forKey: AudioDefaults.vadThreshold) }
    }
    internal var inputGain: Float = 1.0 {
        didSet { UserDefaults.standard.set(inputGain, forKey: AudioDefaults.inputGain) }
    }
    internal var inputNoiseReduction: Bool = false {
        didSet { UserDefaults.standard.set(inputNoiseReduction, forKey: AudioDefaults.inputNoiseReduction) }
    }
    // Capture the mic in stereo (interleaved L/R) instead of mono. Real stereo only reaches the
    // wire on a stereo channel; the core folds a stereo mic to mono on a mono channel. Applied to
    // the core when the mic stream starts (micToggleClicked) and live via SettingsWindowController.
    internal var stereoMic: Bool = false {
        didSet { UserDefaults.standard.set(stereoMic, forKey: AudioDefaults.stereoMic) }
    }
    internal var selectedInputDeviceId: String?

    // Aux outgoing stream: a second hardware input device the client captures itself and feeds to
    // the core (kind = AUX_DEVICE, external_feed). Device + volume only — aux is always-on (the
    // core never gates AUX_DEVICE on VAD/PTT). auxDeviceUID is a Core Audio device UID (stable),
    // NOT a core/miniaudio id. auxGain is read live by the capture feed, so the slider is instant.
    internal var auxEnabled: Bool = false {
        didSet { UserDefaults.standard.set(auxEnabled, forKey: AudioDefaults.auxEnabled) }
    }
    internal var auxDeviceUID: String? {
        didSet { UserDefaults.standard.set(auxDeviceUID, forKey: AudioDefaults.auxDeviceUID) }
    }
    internal var auxGain: Float = 1.0 {
        didSet { UserDefaults.standard.set(auxGain, forKey: AudioDefaults.auxGain) }
    }

    // MARK: - UI components
    private let channelOutlineView = NSOutlineView()
    private let userTableView = NSTableView()
    private let logTextView: NSTextView = {
        let tv = NSTextView()
        tv.isEditable = false
        tv.isSelectable = true
        tv.isAutomaticQuoteSubstitutionEnabled = false
        tv.textContainerInset = NSSize(width: 4, height: 4)
        return tv
    }()
    private let statusLabel = NSTextField(labelWithString: "")
    private let composeField = NSTextField()
    private let sendButton = NSButton()

    // MARK: - Toolbar controls (kept as fields so we can update their state)

    private var joinVoiceButton: NSButton?
    private var joinVoiceItem: NSToolbarItem?
    private var shareScreenButton: NSButton?
    private var shareScreenItem: NSToolbarItem?
    private var muteButton: NSButton?
    private var deafenButton: NSButton?
    private var outputVolumeSlider: NSSlider?

    // MARK: - Init

    init(client: VoiceCatClient, selfUserId: UInt32, nickname: String) {
        self.client = client
        self.selfUserId = selfUserId
        self.nickname = nickname
        let window = NSWindow(
            contentRect: NSRect(x: 0, y: 0, width: 900, height: 600),
            styleMask: [.titled, .closable, .miniaturizable, .resizable],
            backing: .buffered,
            defer: false
        )
        window.title = "VoiceCat — \(nickname)"
        window.minSize = NSSize(width: 640, height: 480)
        window.center()
        super.init(window: window)
        window.delegate = self

        loadPersistedAudioSettings()
        buildUI()
        buildToolbar()
        wireEvents()
        bootstrap()
    }

    /// Restore the saved input mode / VAD threshold / mic gain / PTT key from UserDefaults so a
    /// relaunch keeps the user's transmission settings instead of resetting to VAD defaults.
    private func loadPersistedAudioSettings() {
        let d = UserDefaults.standard
        if d.object(forKey: AudioDefaults.inputMode) != nil {
            let raw = UInt32(d.integer(forKey: AudioDefaults.inputMode))
            selectedInputMode = VoiceCatInputMode(rawValue: raw) ?? .voiceActivation
        }
        if d.object(forKey: AudioDefaults.vadThreshold) != nil {
            vadThresholdValue = d.float(forKey: AudioDefaults.vadThreshold)
        }
        if d.object(forKey: AudioDefaults.inputGain) != nil {
            inputGain = d.float(forKey: AudioDefaults.inputGain)
        }
        if d.object(forKey: AudioDefaults.inputNoiseReduction) != nil {
            inputNoiseReduction = d.bool(forKey: AudioDefaults.inputNoiseReduction)
        }
        stereoMic = d.bool(forKey: AudioDefaults.stereoMic)
        if d.object(forKey: AudioDefaults.pttKeyCode) != nil {
            pttKeyCode = UInt16(d.integer(forKey: AudioDefaults.pttKeyCode))
        }
        auxEnabled = d.bool(forKey: AudioDefaults.auxEnabled)
        if d.object(forKey: AudioDefaults.auxDeviceUID) != nil {
            auxDeviceUID = d.string(forKey: AudioDefaults.auxDeviceUID)
        }
        if d.object(forKey: AudioDefaults.auxGain) != nil {
            auxGain = d.float(forKey: AudioDefaults.auxGain)
        }
    }

    required init?(coder: NSCoder) { fatalError() }

    deinit {
        NSLog("[VoiceCatMac] MainWindowController deinit — client and event handlers are gone")
    }

    // MARK: - UI construction

    private func buildUI() {
        guard let contentView = window?.contentView else { return }

        // Outer split: left panel (channels+users) | right panel (unified log)
        let outerSplit = NSSplitView()
        outerSplit.isVertical = true
        outerSplit.dividerStyle = .thin
        outerSplit.translatesAutoresizingMaskIntoConstraints = false

        // Left panel: channel outline + user table stacked vertically
        let innerSplit = NSSplitView()
        innerSplit.isVertical = false
        innerSplit.dividerStyle = .thin
        innerSplit.translatesAutoresizingMaskIntoConstraints = false

        let channelPanel = buildChannelPanel()
        let userPanel = buildUserPanel()
        innerSplit.addArrangedSubview(channelPanel)
        innerSplit.addArrangedSubview(userPanel)
        innerSplit.setHoldingPriority(.defaultLow, forSubviewAt: 0)
        innerSplit.setHoldingPriority(.defaultLow + 1, forSubviewAt: 1)

        // Right panel: unified log (chat + activity in one text view)
        let logPanel = buildLogPanel()

        outerSplit.addArrangedSubview(innerSplit)
        outerSplit.addArrangedSubview(logPanel)
        outerSplit.setHoldingPriority(.defaultLow, forSubviewAt: 0)
        outerSplit.setHoldingPriority(.defaultLow + 1, forSubviewAt: 1)

        let statusBar = buildStatusBar()
        let composeBar = buildComposeBar()

        contentView.addSubview(outerSplit)
        contentView.addSubview(statusBar)
        contentView.addSubview(composeBar)

        NSLayoutConstraint.activate([
            outerSplit.topAnchor.constraint(equalTo: contentView.topAnchor),
            outerSplit.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
            outerSplit.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
            outerSplit.bottomAnchor.constraint(equalTo: statusBar.topAnchor),

            statusBar.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
            statusBar.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
            statusBar.bottomAnchor.constraint(equalTo: composeBar.topAnchor),
            statusBar.heightAnchor.constraint(equalToConstant: 24),

            composeBar.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
            composeBar.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
            composeBar.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
            composeBar.heightAnchor.constraint(equalToConstant: 36),
        ])

        // Set initial divider positions so panels get reasonable space instead of
        // collapsing to zero. These are applied after the window is on screen.
        DispatchQueue.main.async { [weak self, weak outerSplit, weak innerSplit] in
            // outerSplit is vertical (left|right): divide at 40% from left
            outerSplit?.setPosition(360, ofDividerAt: 0)
            // innerSplit is horizontal (channels on top, users below): divide at 60% from top
            innerSplit?.setPosition(260, ofDividerAt: 0)
            _ = self
        }
    }

    private func buildChannelPanel() -> NSView {
        let col = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("channel"))
        channelOutlineView.addTableColumn(col)
        channelOutlineView.headerView = nil
        channelOutlineView.outlineTableColumn = col
        channelOutlineView.dataSource = self
        channelOutlineView.delegate = self
        channelOutlineView.doubleAction = #selector(channelDoubleClicked)
        channelOutlineView.target = self
        channelOutlineView.setAccessibilityLabel("Channel list")

        let sv = NSScrollView()
        sv.documentView = channelOutlineView
        sv.hasVerticalScroller = true
        sv.borderType = .noBorder
        sv.translatesAutoresizingMaskIntoConstraints = false

        let menu = NSMenu()
        menu.delegate = self
        channelOutlineView.menu = menu

        return sv
    }

    private func buildUserPanel() -> NSView {
        let col = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("user"))
        userTableView.addTableColumn(col)
        userTableView.headerView = nil
        userTableView.dataSource = self
        userTableView.delegate = self
        userTableView.doubleAction = #selector(userDoubleClicked)
        userTableView.target = self
        userTableView.setAccessibilityLabel("Users in current channel")

        let menu = NSMenu()
        menu.delegate = self
        userTableView.menu = menu

        let sv = NSScrollView()
        sv.documentView = userTableView
        sv.hasVerticalScroller = true
        sv.borderType = .noBorder
        sv.translatesAutoresizingMaskIntoConstraints = false
        return sv
    }

    private func buildLogPanel() -> NSView {
        logTextView.setAccessibilityLabel("Chat and activity log")
        logTextView.setAccessibilityHelp("Combined history of chat messages and activity events")

        let sv = NSScrollView()
        sv.documentView = logTextView
        sv.hasVerticalScroller = true
        sv.borderType = .noBorder
        sv.translatesAutoresizingMaskIntoConstraints = false
        return sv
    }

    private func buildStatusBar() -> NSView {
        let bar = NSView()
        bar.translatesAutoresizingMaskIntoConstraints = false
        statusLabel.translatesAutoresizingMaskIntoConstraints = false
        statusLabel.textColor = .secondaryLabelColor
        statusLabel.font = .systemFont(ofSize: NSFont.smallSystemFontSize)
        bar.addSubview(statusLabel)
        NSLayoutConstraint.activate([
            statusLabel.leadingAnchor.constraint(equalTo: bar.leadingAnchor, constant: 8),
            statusLabel.trailingAnchor.constraint(equalTo: bar.trailingAnchor, constant: -8),
            statusLabel.centerYAnchor.constraint(equalTo: bar.centerYAnchor),
        ])
        let sep = NSBox()
        sep.boxType = .separator
        sep.translatesAutoresizingMaskIntoConstraints = false
        bar.addSubview(sep)
        NSLayoutConstraint.activate([
            sep.topAnchor.constraint(equalTo: bar.topAnchor),
            sep.leadingAnchor.constraint(equalTo: bar.leadingAnchor),
            sep.trailingAnchor.constraint(equalTo: bar.trailingAnchor),
            sep.heightAnchor.constraint(equalToConstant: 1),
        ])
        return bar
    }

    private func buildComposeBar() -> NSView {
        let bar = NSView()
        bar.translatesAutoresizingMaskIntoConstraints = false

        composeField.placeholderString = "Type a message to the current channel…"
        composeField.setAccessibilityLabel("Compose channel message")
        composeField.target = self; composeField.action = #selector(sendClicked)

        sendButton.title = "Send"
        sendButton.bezelStyle = .rounded
        sendButton.target = self; sendButton.action = #selector(sendClicked)
        sendButton.setAccessibilityLabel("Send message")
        sendButton.keyEquivalent = "\r"

        let sep = NSBox(); sep.boxType = .separator; sep.translatesAutoresizingMaskIntoConstraints = false
        [sep, composeField, sendButton].forEach { v in
            v.translatesAutoresizingMaskIntoConstraints = false
            bar.addSubview(v)
        }
        NSLayoutConstraint.activate([
            sep.topAnchor.constraint(equalTo: bar.topAnchor),
            sep.leadingAnchor.constraint(equalTo: bar.leadingAnchor),
            sep.trailingAnchor.constraint(equalTo: bar.trailingAnchor),
            sep.heightAnchor.constraint(equalToConstant: 1),

            composeField.leadingAnchor.constraint(equalTo: bar.leadingAnchor, constant: 8),
            composeField.trailingAnchor.constraint(equalTo: sendButton.leadingAnchor, constant: -6),
            composeField.centerYAnchor.constraint(equalTo: bar.centerYAnchor),

            sendButton.trailingAnchor.constraint(equalTo: bar.trailingAnchor, constant: -8),
            sendButton.centerYAnchor.constraint(equalTo: bar.centerYAnchor),
            sendButton.widthAnchor.constraint(equalToConstant: 60),
        ])
        return bar
    }

    private func hstack(_ views: [NSView]) -> NSStackView {
        let s = NSStackView(views: views)
        s.orientation = .horizontal
        s.spacing = 8
        s.alignment = .centerY
        return s
    }

    // MARK: - Toolbar

    private enum ToolbarID {
        static let joinVoice = NSToolbarItem.Identifier("vc.joinVoice")
        static let shareScreen = NSToolbarItem.Identifier("vc.shareScreen")
        static let mute = NSToolbarItem.Identifier("vc.mute")
        static let deafen = NSToolbarItem.Identifier("vc.deafen")
        static let outputVolume = NSToolbarItem.Identifier("vc.outputVolume")
    }

    private func buildToolbar() {
        let toolbar = NSToolbar(identifier: "VoiceCatMainToolbar")
        toolbar.delegate = self
        toolbar.displayMode = .iconAndLabel
        toolbar.showsBaselineSeparator = true
        window?.toolbar = toolbar
    }

    private func makeToolbarButton(symbol: String, label: String, action: Selector,
                                   accessibilityLabel: String) -> NSButton {
        let btn = NSButton()
        btn.bezelStyle = .inline
        btn.image = NSImage(systemSymbolName: symbol, accessibilityDescription: accessibilityLabel)
        btn.imagePosition = .imageLeading
        btn.title = label
        btn.font = .systemFont(ofSize: NSFont.smallSystemFontSize)
        btn.target = self
        btn.action = action
        btn.setAccessibilityLabel(accessibilityLabel)
        return btn
    }

    // MARK: - Event wiring

    private func wireEvents() {
        client.onEvent = { [weak self] event in self?.handleEvent(event) }
        client.onLevel = { [weak self] streamId, rms in self?.handleLevel(streamId, rms) }

        pttMonitor = NSEvent.addLocalMonitorForEvents(matching: [.keyDown, .keyUp]) { [weak self] event in
            guard let self, self.selectedInputMode == .pushToTalk,
                  self.micStreamId != 0, event.keyCode == self.pttKeyCode else { return event }
            let down = event.type == .keyDown
            self.client.setPushToTalk(down)
            // Cue only on the press transition — key-down auto-repeats while held.
            if down && !self.pttEngaged { EventFeedback.shared.play(.ptt) }
            self.pttEngaged = down
            return nil
        }

        // Observe PM window close so we can drop the controller from pmWindows.
        NotificationCenter.default.addObserver(
            forName: NSWindow.willCloseNotification, object: nil, queue: .main
        ) { [weak self] note in
            guard let self,
                  let window = note.object as? NSWindow,
                  let wc = window.windowController as? PrivateMessageWindowController else { return }
            self.pmWindows.removeValue(forKey: wc.otherUserId)
        }
    }

    // MARK: - Bootstrap

    private func bootstrap() {
        channels = client.listChannels()
        let allUsers = client.listUsers()
        users.removeAll()
        for u in allUsers {
            users[u.id] = u
            if u.id == selfUserId { currentChannelId = u.channelId }
        }
        ownPermissions = client.getPermissions()

        NSLog("[VoiceCatMac] bootstrap: channels=%d users=%d perms{admin=%d kick=%d} currentChannelId=%u",
              channels.count, allUsers.count,
              ownPermissions.isAdmin, ownPermissions.canKick, currentChannelId)

        // Apply initial output volume (default 80% — matches Windows client)
        client.setOutputVolume(0.8)

        refreshChannelTree()
        refreshUserList()
        updateStatusLabel()
        buildSettingsMenuItem()
        buildVoiceMenu()
        buildMessagesMenu()
        buildAdminMenu()
        addActivity("Connected to server as \(nickname)")
        EventFeedback.shared.play(.login)
        EventFeedback.shared.speak("Connected")
    }

    // MARK: - Event handling

    private func handleEvent(_ event: VoiceCatEvent) {
        NSLog("[VoiceCatMac] event type=%d result=%d userId=%u channelId=%u streamId=%u text=%@",
              event.type.rawValue, event.result.rawValue, event.userId, event.channelId,
              event.streamId, event.text ?? "(nil)")
        switch event.type {
        case .channelList:
            channels = client.listChannels()
            let allUsers = client.listUsers()
            users.removeAll()
            for u in allUsers {
                users[u.id] = u
                if u.id == selfUserId { currentChannelId = u.channelId }
            }
            refreshChannelTree(); refreshUserList()

        case .userJoined:
            let u = User(id: event.userId,
                         nickname: event.text ?? "User#\(event.userId)",
                         isGuest: true,
                         channelId: event.channelId,
                         selfMicMuted: false, selfDeafened: false,
                         serverMuted: false, serverDeafened: false)
            users[event.userId] = u
            refreshChannelTree(); refreshUserList()
            if event.channelId == currentChannelId && event.userId != selfUserId {
                addActivity("\(u.nickname) joined the channel")
                EventFeedback.shared.play(.channelJoin)
                EventFeedback.shared.speak("\(u.nickname) joined")
            }

        case .userLeft:
            let nick = users[event.userId]?.nickname ?? "User#\(event.userId)"
            let wasHere = users[event.userId]?.channelId == currentChannelId && event.userId != selfUserId
            users.removeValue(forKey: event.userId)
            talkingUsers.remove(event.userId)
            refreshChannelTree(); refreshUserList()
            if wasHere {
                addActivity("\(nick) left the channel")
                EventFeedback.shared.play(.channelLeave)
                EventFeedback.shared.speak("\(nick) left")
            }
            if let pmWin = pmWindows[event.userId] {
                pmWin.appendActivity("\(nick) disconnected from server")
            }

        case .userUpdated:
            let allUsers = client.listUsers()
            users.removeAll()
            for u in allUsers {
                users[u.id] = u
                if u.id == selfUserId {
                    currentChannelId = u.channelId
                    applyServerMuteState(muted: u.serverMuted, deafened: u.serverDeafened)
                }
            }
            refreshChannelTree(); refreshUserList()

        case .joinResult:
            if event.result == .ok {
                currentChannelId = event.channelId
                if let self_ = users[selfUserId] {
                    users[selfUserId] = User(id: self_.id, nickname: self_.nickname, isGuest: self_.isGuest,
                                            channelId: event.channelId,
                                            selfMicMuted: self_.selfMicMuted, selfDeafened: self_.selfDeafened,
                                            serverMuted: self_.serverMuted, serverDeafened: self_.serverDeafened)
                }
                refreshChannelTree(); refreshUserList(); updateStatusLabel()
                let name = channels.first(where: { $0.id == event.channelId })?.name ?? "Channel #\(event.channelId)"
                addActivity("Joined \(name)")
                NSAccessibility.post(element: logTextView, notification: .announcementRequested,
                                     userInfo: [.announcement: "Joined \(name)", .priority: NSAccessibilityPriorityLevel.medium])
            } else {
                addActivity("Could not join channel: \(event.text ?? "\(event.result)")")
            }

        case .genericResult:
            let prefix = event.result == .ok ? "Success" : "Failed"
            let detail = event.text.map { ": \($0)" } ?? ""
            addActivity("\(prefix)\(detail) (\(event.result))")

        case .accountList:
            addActivity("Account list updated")

        case .textMessage:
            appendChatMessage(event)

        case .talkState:
            let talking = event.u32a == 1
            if talking { talkingUsers.insert(event.userId) } else { talkingUsers.remove(event.userId) }
            refreshUserList()
            if event.userId == selfUserId {
                EventFeedback.shared.play(talking ? .vaStart : .vaStop)
            }
            if talking && event.userId != selfUserId,
               let u = users[event.userId], u.channelId == currentChannelId {
                addActivity("\(u.nickname) started talking")
                NSAccessibility.post(element: logTextView, notification: .announcementRequested,
                                     userInfo: [.announcement: "\(u.nickname) started talking",
                                                .priority: NSAccessibilityPriorityLevel.medium])
            }

        case .streamStarted:
            // Our own SCREEN_AUDIO stream is now live on the server — begin capture.
            if event.userId == selfUserId && event.streamId == screenStreamId {
                startScreenCapture()
            }
            guard let u = users[event.userId], u.channelId == currentChannelId else { break }
            let streams = client.listUserStreams(event.userId)
            let kind = streams.first(where: { $0.id == event.streamId })?.kind
            let kindStr = kind == .screenAudio ? "screen audio" : kind == .auxDevice ? "aux device" : "microphone"
            addActivity("\(u.nickname) started \(kindStr) stream")

        case .streamStopped:
            if let u = users[event.userId], u.channelId == currentChannelId {
                addActivity("\(u.nickname) stopped a stream")
            }

        case .disconnected:
            handleDisconnected(event)

        default:
            break
        }
    }

    private func handleLevel(_ streamId: UInt32, _ rms: Float) {
        guard streamId == micStreamId else { return }
        settingsWindowController?.updateLevel(rms: rms)
    }

    // MARK: - Chat / unified log

    private func appendChatMessage(_ event: VoiceCatEvent) {
        let time: String
        if event.timestampUnixMs > 0 {
            let date = Date(timeIntervalSince1970: Double(event.timestampUnixMs) / 1000.0)
            time = DateFormatter.localizedString(from: date, dateStyle: .none, timeStyle: .short)
        } else {
            time = DateFormatter.localizedString(from: Date(), dateStyle: .none, timeStyle: .short)
        }
        let sender = nickname(for: event.userId)
        let isSelf = event.userId == selfUserId
        let body = event.text ?? ""

        if event.textScope == .private {
            // Route PMs to per-conversation windows. For our own outgoing PM, ev.channelId
            // carries the recipient user ID; for incoming, ev.userId is the sender.
            let otherId = isSelf ? event.channelId : event.userId
            let win = getOrOpenPmWindow(otherId)
            win.appendMessage(time: time, isSelf: isSelf, sender: sender, text: body)
            EventFeedback.shared.play(isSelf ? .pmSent : .pmRecv)
            if !isSelf {
                addActivity("Private message from \(sender)")
                EventFeedback.shared.speak("Private message from \(sender): \(body)")
            }
        } else {
            let line = "[\(time)] \(sender): \(body)\n"
            logTextView.textStorage?.append(NSAttributedString(string: line))
            logTextView.scrollToEndOfDocument(nil)
            EventFeedback.shared.play(isSelf ? .channelSent : .channelRecv)
            if !isSelf {
                EventFeedback.shared.speak("\(sender): \(body)")
            }
        }
    }

    // MARK: - Activity log (appended to the unified log in gray)

    private func addActivity(_ text: String) {
        let time = DateFormatter.localizedString(from: Date(), dateStyle: .none, timeStyle: .short)
        let line = "[\(time)] \(text)\n"
        let attributed = NSAttributedString(string: line, attributes: [
            .foregroundColor: NSColor.secondaryLabelColor,
        ])
        logTextView.textStorage?.append(attributed)
        logTextView.scrollToEndOfDocument(nil)
    }

    // MARK: - UI refresh

    private func refreshChannelTree() {
        channelTree = buildChannelTree(channels)
        channelOutlineView.reloadData()
        channelOutlineView.expandItem(nil, expandChildren: true)
    }

    private func buildChannelTree(_ channels: [Channel]) -> [ChannelNode] {
        var nodeMap: [UInt32: ChannelNode] = [:]
        for ch in channels { nodeMap[ch.id] = ChannelNode(ch) }
        var roots: [ChannelNode] = []
        for ch in channels.sorted(by: { $0.name < $1.name }) {
            if ch.parentId == 0 {
                roots.append(nodeMap[ch.id]!)
            } else if let parent = nodeMap[ch.parentId] {
                parent.children.append(nodeMap[ch.id]!)
            }
        }
        return roots.sorted { $0.channel.name < $1.channel.name }
    }

    private func refreshUserList() {
        displayedUsers = users.values
            .filter { $0.channelId == currentChannelId }
            .sorted { $0.nickname < $1.nickname }
        userTableView.reloadData()
        updateStatusLabel()
    }

    private func updateStatusLabel() {
        var suffix = ""
        if serverMuted { suffix += " [server muted]" }
        if serverDeafened { suffix += " [server deafened]" }
        if currentChannelId == 0 {
            statusLabel.stringValue = "Connected as \(nickname)\(suffix) — not in a channel."
            return
        }
        let chanName = channels.first(where: { $0.id == currentChannelId })?.name ?? "Channel #\(currentChannelId)"
        let count = users.values.filter { $0.channelId == currentChannelId }.count
        statusLabel.stringValue = "Connected as \(nickname)\(suffix) — \(chanName) (\(count) user\(count == 1 ? "" : "s"))"
    }

    private func applyServerMuteState(muted: Bool, deafened: Bool) {
        if muted && !serverMuted { addActivity("You have been server-muted") }
        if deafened && !serverDeafened { addActivity("You have been server-deafened") }
        if !muted && serverMuted { addActivity("Server mute cleared") }
        if !deafened && serverDeafened { addActivity("Server deafen cleared") }
        serverMuted = muted; serverDeafened = deafened
        updateStatusLabel()
    }

    private func handleDisconnected(_ event: VoiceCatEvent) {
        let msg = event.text.map { "Disconnected: \($0)" } ?? "Disconnected from server."
        statusLabel.stringValue = msg
        addActivity(msg)
        EventFeedback.shared.play(event.result == .ok ? .logout : .connectionLost)
        EventFeedback.shared.speak(event.result == .ok ? "Disconnected" : "Connection lost")
        channelTree = []; channelOutlineView.reloadData()
        displayedUsers = []; userTableView.reloadData()
        users.removeAll(); talkingUsers.removeAll()
        stopScreenCapture()
        auxCapture?.stop(); auxCapture = nil  // connection gone — drop capture, no stopStream
        currentChannelId = 0; micStreamId = 0; screenStreamId = 0; auxStreamId = 0
        composeField.isEnabled = false; sendButton.isEnabled = false
        joinVoiceButton?.isEnabled = false
        shareScreenButton?.isEnabled = false
        muteButton?.isEnabled = false
        deafenButton?.isEnabled = false
        // Close all PM windows
        for (_, pmWin) in pmWindows { pmWin.close() }
        pmWindows.removeAll()
    }

    // MARK: - Admin menu

    private func buildAdminMenu() {
        guard ownPermissions.canAdminAccounts || ownPermissions.isAdmin else { return }
        let adminMenu = NSMenu(title: "Admin")
        let accountsItem = NSMenuItem(title: "Server accounts…", action: #selector(openAccounts), keyEquivalent: "")
        accountsItem.target = self
        adminMenu.addItem(accountsItem)
        let topItem = NSMenuItem(title: "Admin", action: nil, keyEquivalent: "")
        topItem.submenu = adminMenu
        adminMenuItem = topItem
        NSApp.mainMenu?.addItem(topItem)
    }

    @objc private func openAccounts() {
        presentSheet(AccountsSheet(client: client))
    }

    // MARK: - Channel actions

    @objc private func channelDoubleClicked() {
        guard let node = channelOutlineView.item(atRow: channelOutlineView.clickedRow) as? ChannelNode else { return }
        joinChannelRequest(node.channel.id)
    }

    private func joinChannelRequest(_ channelId: UInt32) {
        guard channelId != currentChannelId else { return }
        let channel = channels.first(where: { $0.id == channelId })
        if channel?.passwordProtected == true {
            let sheet = PasswordPromptSheet(prompt: "Password for channel \"\(channel!.name)\":")
            sheet.onComplete = { [weak self] pw in
                self?.client.joinChannel(channelId, password: pw)
            }
            presentSheet(sheet)
        } else {
            client.joinChannel(channelId, password: nil)
        }
    }

    // MARK: - User actions

    @objc private func userDoubleClicked() {
        openUserTuning(row: userTableView.clickedRow)
    }

    private func openUserTuning(row: Int) {
        guard row >= 0, row < displayedUsers.count else { return }
        let user = displayedUsers[row]
        presentSheet(PerUserTuningSheet(client: client, userId: user.id, nickname: user.nickname))
    }

    // MARK: - Voice controls

    @objc private func micToggleClicked() {
        if micStreamId == 0 {
            let (result, streamId) = client.startStream(StreamDescriptor(kind: .mic, deviceId: nil, label: "Microphone"))
            if result == .ok {
                micStreamId = streamId
                if let devId = selectedInputDeviceId {
                    client.setInputDevice(streamId: streamId, deviceId: devId)
                }
                // Stored on the stream before the announce round-trip completes, so the core's
                // first capture-device open (ensure_audio_running) picks up the channel count.
                client.setCaptureChannels(streamId: streamId, channels: stereoMic ? 2 : 1)
                client.setInputMode(selectedInputMode)
                if selectedInputMode == .voiceActivation {
                    client.setVadThreshold(vadThresholdValue)
                }
                client.setInputGain(inputGain)
                client.setInputNoiseReduction(inputNoiseReduction)
                setVoiceJoinedState(true)
                addActivity("Joined voice — microphone active")
                EventFeedback.shared.play(.voiceOn)
                NSAccessibility.post(element: logTextView, notification: .announcementRequested,
                                     userInfo: [.announcement: "Joined voice", .priority: NSAccessibilityPriorityLevel.medium])
                startAuxStream() // no-op unless the aux stream is enabled in settings
            } else {
                addActivity("Failed to start microphone: \(result)")
            }
        } else {
            stopAuxStream()
            client.setPushToTalk(false)
            client.stopStream(micStreamId)
            micStreamId = 0
            settingsWindowController?.resetLevel()
            setVoiceJoinedState(false)
            addActivity("Left voice")
            EventFeedback.shared.play(.voiceOff)
        }
    }

    // MARK: - Aux input stream (second hardware input device)

    /// Called by SettingsWindowController when the user toggles the aux checkbox.
    func applyAuxEnabled(_ on: Bool) {
        auxEnabled = on
        guard micStreamId != 0 else { return } // not in voice — applied on next Join Voice
        if on { startAuxStream() } else { stopAuxStream() }
    }

    /// Called by SettingsWindowController when the user picks a different aux device.
    func applyAuxDevice(_ uid: String?) {
        auxDeviceUID = uid
        if auxStreamId != 0 { restartAuxCapture() }
    }

    private func startAuxStream() {
        guard auxStreamId == 0, auxEnabled else { return }
        let (result, streamId) = client.startStream(
            StreamDescriptor(kind: .auxDevice, deviceId: nil, label: "Aux device", externalFeed: true))
        guard result == .ok else {
            addActivity("Failed to start aux stream: \(result)")
            return
        }
        auxStreamId = streamId
        startAuxCapture()
        addActivity("Aux input stream active")
    }

    private func startAuxCapture() {
        let capture = InputDeviceCapture(deviceUID: auxDeviceUID) { [weak self] ptr, spc, ch in
            self?.feedAux(ptr, samplesPerChannel: spc, channels: ch)
        }
        auxCapture = capture
        do { try capture.start() }
        catch {
            addActivity("Failed to open aux input device")
            stopAuxStream()
        }
    }

    // Re-open the capture on a different device while the aux stream stays up (the core stream id
    // is unchanged — only the client-side capture source changes).
    private func restartAuxCapture() {
        guard auxStreamId != 0 else { return }
        auxCapture?.stop()
        auxCapture = nil
        startAuxCapture()
    }

    private func stopAuxStream() {
        auxCapture?.stop()
        auxCapture = nil
        if auxStreamId != 0 {
            client.stopStream(auxStreamId)
            auxStreamId = 0
        }
    }

    // Fired on the capture's realtime thread. feedPcm is thread-safe. Gain is read live from
    // auxGain each frame so the volume slider takes effect immediately.
    private func feedAux(_ pcm: UnsafePointer<Int16>, samplesPerChannel: Int, channels: UInt32) {
        guard auxStreamId != 0 else { return }
        let gain = auxGain
        if gain != 1.0 {
            let n = samplesPerChannel * Int(channels)
            var scaled = [Int16](repeating: 0, count: n)
            for i in 0..<n {
                let v = (Float(pcm[i]) * gain).rounded()
                scaled[i] = Int16(max(-32768, min(32767, v)))
            }
            client.feedPcm(streamId: auxStreamId, pcm: scaled,
                           samplesPerChannel: samplesPerChannel, channels: channels)
        } else {
            client.feedPcm(streamId: auxStreamId, pcm: pcm,
                           samplesPerChannel: samplesPerChannel, channels: channels)
        }
    }

    /// Update toolbar button labels/states to reflect whether voice is active. Mirrors the
    /// Windows client's `SetVoiceJoinedState`.
    private func setVoiceJoinedState(_ joined: Bool) {
        joinVoiceButton?.title = joined ? "Leave Voice" : "Join Voice"
        joinVoiceButton?.image = NSImage(systemSymbolName: joined ? "mic.fill" : "mic",
                                         accessibilityDescription: joined ? "Leave Voice" : "Join Voice")
        // The text shown beneath a custom-view toolbar item comes from the NSToolbarItem's
        // label, not the inner button's title — update it too or the label stays "Join Voice".
        joinVoiceItem?.label = joined ? "Leave Voice" : "Join Voice"
        joinVoiceItem?.paletteLabel = joined ? "Leave Voice" : "Join Voice"
        joinVoiceButton?.setAccessibilityLabel(joined ? "Leave Voice — stop sending microphone audio"
                                                      : "Join Voice — start sending microphone audio")
        muteButton?.isEnabled = joined
        deafenButton?.isEnabled = joined
    }

    @objc private func screenAudioClicked() {
        if screenStreamId == 0 {
            // Let the user pick what to share (apps to include/exclude, screen-reader audio)
            // before we announce anything. The picker remembers the previous choice.
            let sheet = ScreenSharePickerSheet(selection: screenAudioSelection)
            sheet.onComplete = { [weak self] selection in
                guard let self, let selection else { return }  // nil = cancelled
                self.screenAudioSelection = selection
                self.beginScreenAudioShare()
            }
            presentSheet(sheet)
        } else {
            stopScreenCapture()
            client.stopStream(screenStreamId)
            screenStreamId = 0
            setShareScreenButton(active: false)
            addActivity("Stopped sharing screen audio")
        }
    }

    /// Announce the SCREEN_AUDIO stream with the chosen selection in hand. ScreenCaptureKit
    /// capture starts once the server's StreamAnnounceResult lands (the .streamStarted event),
    /// when the effective audio config — and thus the channel count — is known. See
    /// startScreenCapture.
    private func beginScreenAudioShare() {
        let (result, streamId) = client.startStream(StreamDescriptor(kind: .screenAudio, deviceId: nil, label: "Desktop audio"))
        if result == .ok {
            screenStreamId = streamId
            setShareScreenButton(active: true)
            addActivity("Starting screen audio share…")
        } else {
            addActivity("Failed to start screen audio: \(result)")
        }
    }

    private func setShareScreenButton(active: Bool) {
        shareScreenButton?.title = active ? "Stop Screen Audio" : "Share Screen Audio"
        shareScreenButton?.image = NSImage(
            systemSymbolName: active ? "rectangle.on.rectangle.angled.fill" : "rectangle.on.rectangle.angled",
            accessibilityDescription: active ? "Stop Screen Audio" : "Share Screen Audio")
        // Update the toolbar item's label too (see setVoiceJoinedState) — the inner button's
        // title alone doesn't change the text shown beneath a custom-view toolbar item.
        shareScreenItem?.label = active ? "Stop Screen Audio" : "Share Screen Audio"
        shareScreenItem?.paletteLabel = active ? "Stop Screen Audio" : "Share Screen Audio"
    }

    /// Start ScreenCaptureKit capture for our own SCREEN_AUDIO stream. Called from the
    /// `.streamStarted` event handler, where the effective audio config is available. Captures
    /// in the channel's mode (stereo when the channel is stereo) and feeds 20 ms PCM frames.
    private func startScreenCapture() {
        guard screenStreamId != 0, screenCapture == nil else { return }
        let (cfgResult, cfg) = client.getStreamAudioConfig(userId: selfUserId, streamId: screenStreamId)
        let channels: UInt32 = (cfgResult == .ok && cfg?.stereo == true) ? 2 : 1

        let streamId = screenStreamId
        let capture = ScreenAudioCapture(channels: channels, selection: screenAudioSelection) { [weak self] pcm, samples, ch in
            self?.client.feedPcm(streamId: streamId, pcm: pcm, samplesPerChannel: samples, channels: ch)
        }
        screenCapture = capture

        Task { @MainActor in
            do {
                try await capture.start()
                addActivity("Started sharing screen audio (\(channels == 2 ? "stereo" : "mono"))"
                            + "\(Self.scopeSuffix(for: screenAudioSelection))")
            } catch {
                // Most commonly: Screen Recording permission denied. Roll back the stream.
                screenCapture = nil
                if screenStreamId != 0 {
                    client.stopStream(screenStreamId)
                    screenStreamId = 0
                    setShareScreenButton(active: false)
                }
                addActivity("Screen audio capture failed — grant Screen Recording in System "
                            + "Settings ▸ Privacy & Security, then try again. (\(error))")
            }
        }
    }

    private func stopScreenCapture() {
        screenCapture?.stop()
        screenCapture = nil
    }

    /// A short human-readable description of the share scope for the activity log.
    private static func scopeSuffix(for selection: ScreenAudioSelection) -> String {
        var parts: [String] = []
        switch selection.scope {
        case .entireDesktop: break
        case .onlyApps(let ids):  if !ids.isEmpty { parts.append("only \(ids.count) app(s)") }
        case .allExcept(let ids): if !ids.isEmpty { parts.append("excluding \(ids.count) app(s)") }
        }
        // For .onlyApps the screen reader is already excluded, so don't claim it twice.
        if selection.excludeScreenReader {
            if case .onlyApps = selection.scope {} else { parts.append("no screen reader") }
        }
        return parts.isEmpty ? "" : " — " + parts.joined(separator: ", ")
    }

    @objc private func muteChanged() {
        let muted = muteButton?.state == .on
        let deafened = deafenButton?.state == .on
        client.setSelfMute(micMuted: muted, deafened: deafened)
        muteButton?.image = NSImage(systemSymbolName: muted ? "mic.slash.fill" : "mic.fill",
                                    accessibilityDescription: muted ? "Unmute microphone" : "Mute microphone")
    }

    @objc private func deafenChanged() {
        let muted = muteButton?.state == .on
        let deafened = deafenButton?.state == .on
        client.setSelfMute(micMuted: muted, deafened: deafened)
        deafenButton?.image = NSImage(systemSymbolName: deafened ? "speaker.slash.fill" : "speaker.wave.2.fill",
                                      accessibilityDescription: deafened ? "Undeafen" : "Deafen")
    }

    // MARK: - Text send

    @objc private func sendClicked() {
        let msg = composeField.stringValue.trimmingCharacters(in: .whitespaces)
        guard !msg.isEmpty else { return }
        guard currentChannelId != 0 else { return }
        client.sendText(scope: .channel, targetId: currentChannelId, text: msg)
        composeField.stringValue = ""
    }

    // MARK: - M5: Moderation helpers

    private func moveUser(_ user: User) {
        let sheet = MoveUserSheet(channels: channels, currentChannelId: user.channelId)
        sheet.onComplete = { [weak self] channelId in
            guard let channelId else { return }
            self?.client.moveUser(user.id, toChannel: channelId)
        }
        presentSheet(sheet)
    }

    private func kickUser(_ user: User) {
        let sheet = InputSheet(title: "Kick user", prompt: "Reason:", defaultValue: "Kicked by admin")
        sheet.onComplete = { [weak self] reason in
            self?.client.kickUser(user.id, reason: reason)
        }
        presentSheet(sheet)
    }

    private func banUser(_ user: User) {
        let sheet = BanUserSheet(nickname: user.nickname)
        sheet.onComplete = { [weak self] (reason, expiresMs) in
            self?.client.banUser(user.id, reason: reason, expiresUnixMs: expiresMs)
        }
        presentSheet(sheet)
    }

    private func setPermissions(_ user: User) {
        let sheet = PermissionsSheet(nickname: user.nickname, current: Permissions(
            canCreateTempChannel: false, canKick: false, canBan: false,
            canMoveUsers: false, canAdminAccounts: false, isAdmin: false))
        sheet.onComplete = { [weak self] perms in
            guard let perms else { return }
            self?.client.setPermission(user.id, perms: perms)
        }
        presentSheet(sheet)
    }

    private func toggleServerMute(_ user: User) {
        client.setServerMute(user.id, muted: !user.serverMuted, deafened: user.serverDeafened)
    }

    private func toggleServerDeafen(_ user: User) {
        client.setServerMute(user.id, muted: user.serverMuted, deafened: !user.serverDeafened)
    }

    // MARK: - Helpers

    private func nickname(for userId: UInt32) -> String {
        if userId == selfUserId { return nickname }
        return users[userId]?.nickname ?? "User#\(userId)"
    }

    private func presentSheet(_ vc: NSViewController) {
        if let cvc = window?.contentViewController {
            cvc.presentAsSheet(vc)
        } else {
            let cvc = NSViewController()
            cvc.view = window!.contentView!
            window?.contentViewController = cvc
            cvc.presentAsSheet(vc)
        }
    }

    private func selectedUserInTable() -> User? {
        let row = userTableView.selectedRow
        guard row >= 0, row < displayedUsers.count else { return nil }
        return displayedUsers[row]
    }

    // MARK: - Voice menu (⌘⇧V / ⌘⇧S / ⌘⇧M / ⌘⇧D)

    private func buildVoiceMenu() {
        let menu = NSMenu(title: "Voice")

        let joinItem = NSMenuItem(title: "Join Voice", action: #selector(micToggleClicked),
                                  keyEquivalent: "v")
        joinItem.keyEquivalentModifierMask = [.command, .shift]
        joinItem.target = self
        menu.addItem(joinItem)

        let screenItem = NSMenuItem(title: "Share Screen Audio", action: #selector(screenAudioClicked),
                                    keyEquivalent: "s")
        screenItem.keyEquivalentModifierMask = [.command, .shift]
        screenItem.target = self
        menu.addItem(screenItem)

        menu.addItem(.separator())

        let muteItem = NSMenuItem(title: "Mute", action: #selector(muteClicked),
                                  keyEquivalent: "m")
        muteItem.keyEquivalentModifierMask = [.command, .shift]
        muteItem.target = self
        menu.addItem(muteItem)

        let deafenItem = NSMenuItem(title: "Deafen", action: #selector(deafenClicked),
                                    keyEquivalent: "d")
        deafenItem.keyEquivalentModifierMask = [.command, .shift]
        deafenItem.target = self
        menu.addItem(deafenItem)

        let topItem = NSMenuItem(title: "Voice", action: nil, keyEquivalent: "")
        topItem.submenu = menu
        voiceMenuItem = topItem
        NSApp.mainMenu?.addItem(topItem)
    }

    /// Toolbar/menu toggle for self-mute. Flips the toolbar button state and applies.
    @objc private func muteClicked() {
        muteButton?.state = (muteButton?.state == .on) ? .off : .on
        muteChanged()
    }

    /// Toolbar/menu toggle for self-deafen. Flips the toolbar button state and applies.
    @objc private func deafenClicked() {
        deafenButton?.state = (deafenButton?.state == .on) ? .off : .on
        deafenChanged()
    }

    // MARK: - Messages menu (⌘⇧N for New Private Message)

    private func buildMessagesMenu() {
        let menu = NSMenu(title: "Messages")

        let newPmItem = NSMenuItem(title: "New Private Message…", action: #selector(openNewPmDialog),
                                   keyEquivalent: "n")
        newPmItem.keyEquivalentModifierMask = [.command, .shift]
        newPmItem.target = self
        menu.addItem(newPmItem)

        let topItem = NSMenuItem(title: "Messages", action: nil, keyEquivalent: "")
        topItem.submenu = menu
        messagesMenuItem = topItem
        NSApp.mainMenu?.addItem(topItem)
    }

    // MARK: - Private messaging

    /// Get or open a PM window for the given user. If one already exists, bring it to front.
    @discardableResult
    private func getOrOpenPmWindow(_ userId: UInt32) -> PrivateMessageWindowController {
        if let existing = pmWindows[userId], existing.window != nil {
            existing.window?.makeKeyAndOrderFront(nil)
            return existing
        }
        let nick = nickname(for: userId)
        let wc = PrivateMessageWindowController(client: client, otherUserId: userId,
                                                nickname: nick, selfUserId: selfUserId)
        pmWindows[userId] = wc
        wc.showWindow(nil)
        return wc
    }

    /// Open the PM picker dialog (Messages → New Private Message…). Lists all server users.
    @objc private func openNewPmDialog() {
        let others = users.values
            .filter { $0.id != selfUserId }
            .sorted { $0.nickname < $1.nickname }
        if others.isEmpty {
            let alert = NSAlert()
            alert.messageText = "No other users are connected to the server."
            alert.addButton(withTitle: "OK")
            guard let window else { return }
            alert.beginSheetModal(for: window) { _ in }
            return
        }
        let sheet = UserPickerSheet(users: others)
        sheet.onComplete = { [weak self] userId in
            guard let userId else { return }
            self?.getOrOpenPmWindow(userId)
        }
        presentSheet(sheet)
    }

    /// Open a PM window from the user context menu.
    private func openPmWindow(_ user: User) {
        getOrOpenPmWindow(user.id)
    }

    // MARK: - Output volume

    @objc private func outputVolumeSliderChanged() {
        let gain = Float(outputVolumeSlider?.doubleValue ?? 80) / 100.0
        client.setOutputVolume(gain)
    }

    // MARK: - Settings window (⌘,)

    /// Add "Settings…" to the app menu (the first menu, named after the app). Mac convention:
    /// Settings lives in the app menu with ⌘, key equivalent.
    private func buildSettingsMenuItem() {
        guard let appMenu = NSApp.mainMenu?.item(at: 0)?.submenu else { return }
        let settingsItem = NSMenuItem(title: "Settings…", action: #selector(openSettings),
                                      keyEquivalent: ",")
        settingsItem.target = self
        // Insert before the Quit item (or at the end if Quit isn't found)
        if let quitIndex = appMenu.items.firstIndex(where: { $0.keyEquivalent == "q" }) {
            appMenu.insertItem(settingsItem, at: quitIndex)
            appMenu.insertItem(NSMenuItem.separator(), at: quitIndex)
        } else {
            appMenu.addItem(NSMenuItem.separator())
            appMenu.addItem(settingsItem)
        }
        settingsMenuItem = settingsItem
    }

    @objc private func openSettings() {
        if let existing = settingsWindowController, existing.window != nil {
            existing.window?.makeKeyAndOrderFront(nil)
            return
        }
        let wc = SettingsWindowController(client: client, mainController: self)
        settingsWindowController = wc
        wc.showWindow(nil)
    }

    // MARK: - NSWindowDelegate

    func windowWillClose(_ notification: Notification) {
        if let mon = pttMonitor { NSEvent.removeMonitor(mon) }
        NotificationCenter.default.removeObserver(self)
        // Close all PM windows
        for (_, pmWin) in pmWindows { pmWin.close() }
        pmWindows.removeAll()
        // Close settings window
        settingsWindowController?.close()
        settingsWindowController = nil
        // Remove app menus we added
        if let item = voiceMenuItem { NSApp.mainMenu?.removeItem(item) }
        if let item = messagesMenuItem { NSApp.mainMenu?.removeItem(item) }
        if let item = adminMenuItem { NSApp.mainMenu?.removeItem(item) }
        // Remove Settings menu item + separator from app menu
        if let appMenu = NSApp.mainMenu?.item(at: 0)?.submenu {
            if let item = settingsMenuItem { appMenu.removeItem(item) }
            // Remove the separator we inserted before Quit
            for mi in appMenu.items where mi.isSeparatorItem {
                if let quitIndex = appMenu.items.firstIndex(where: { $0.keyEquivalent == "q" }),
                   let sepIndex = appMenu.items.firstIndex(of: mi),
                   sepIndex == quitIndex - 1 {
                    appMenu.removeItem(at: sepIndex)
                    break
                }
            }
        }
        client.setPushToTalk(false)
        stopScreenCapture()
        if auxStreamId != 0 { stopAuxStream() }
        if screenStreamId != 0 { client.stopStream(screenStreamId) }
        if micStreamId != 0 { client.stopStream(micStreamId) }
        client.onLevel = nil
        client.onEvent = nil
        client.disconnect()
    }
}

// MARK: - NSOutlineView DataSource / Delegate

extension MainWindowController: NSOutlineViewDataSource, NSOutlineViewDelegate {
    func outlineView(_ outlineView: NSOutlineView, numberOfChildrenOfItem item: Any?) -> Int {
        if item == nil { return channelTree.count }
        return (item as! ChannelNode).children.count
    }

    func outlineView(_ outlineView: NSOutlineView, child index: Int, ofItem item: Any?) -> Any {
        if item == nil { return channelTree[index] }
        return (item as! ChannelNode).children[index]
    }

    func outlineView(_ outlineView: NSOutlineView, isItemExpandable item: Any) -> Bool {
        !(item as! ChannelNode).children.isEmpty
    }

    func outlineView(_ outlineView: NSOutlineView, viewFor tableColumn: NSTableColumn?, item: Any) -> NSView? {
        let node = item as! ChannelNode
        let ch = node.channel
        let userCount = users.values.filter { $0.channelId == ch.id }.count
        var label = "\(ch.name) (\(userCount))"
        if ch.passwordProtected { label += " [pw]" }
        if ch.id == currentChannelId { label += " ►" }
        let id = NSUserInterfaceItemIdentifier("channelCell")
        let cell = outlineView.makeView(withIdentifier: id, owner: nil) as? NSTableCellView
                   ?? makeTextCellView(identifier: id)
        cell.textField?.stringValue = label
        cell.setAccessibilityLabel(label)
        return cell
    }

    func outlineView(_ outlineView: NSOutlineView, shouldSelectItem item: Any) -> Bool { true }
}

// MARK: - NSTableView DataSource / Delegate

extension MainWindowController: NSTableViewDataSource, NSTableViewDelegate {
    func numberOfRows(in tableView: NSTableView) -> Int {
        displayedUsers.count
    }

    func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? {
        let id = NSUserInterfaceItemIdentifier("cell")
        let cell = tableView.makeView(withIdentifier: id, owner: nil) as? NSTableCellView
                   ?? makeTextCellView(identifier: id)
        let user = displayedUsers[row]
        var label = user.nickname
        if user.id == selfUserId { label += " (you)" }
        if talkingUsers.contains(user.id) { label += " (talking)" }
        if user.selfMicMuted || user.serverMuted { label += " (muted)" }
        if user.selfDeafened || user.serverDeafened { label += " (deafened)" }
        cell.textField?.stringValue = label
        cell.setAccessibilityLabel(label)
        return cell
    }

    private func makeTextCellView(identifier: NSUserInterfaceItemIdentifier) -> NSTableCellView {
        let cell = NSTableCellView()
        cell.identifier = identifier
        let tf = NSTextField(labelWithString: "")
        tf.translatesAutoresizingMaskIntoConstraints = false
        cell.addSubview(tf); cell.textField = tf
        NSLayoutConstraint.activate([
            tf.leadingAnchor.constraint(equalTo: cell.leadingAnchor, constant: 4),
            tf.trailingAnchor.constraint(equalTo: cell.trailingAnchor, constant: -4),
            tf.centerYAnchor.constraint(equalTo: cell.centerYAnchor),
        ])
        return cell
    }
}

// MARK: - NSMenuDelegate (context menus)

extension MainWindowController: NSMenuDelegate {
    func menuNeedsUpdate(_ menu: NSMenu) {
        menu.removeAllItems()
        if menu === channelOutlineView.menu {
            buildChannelContextMenu(menu)
        } else if menu === userTableView.menu {
            buildUserContextMenu(menu)
        }
    }

    private func buildChannelContextMenu(_ menu: NSMenu) {
        let row = channelOutlineView.clickedRow
        if row >= 0, let node = channelOutlineView.item(atRow: row) as? ChannelNode {
            menu.addItem(withTitle: "Join", action: #selector(channelContextJoin), keyEquivalent: "").target = self
            menu.addItem(.separator())
            if ownPermissions.isAdmin {
                let edit = menu.addItem(withTitle: "Edit channel…", action: #selector(channelContextEdit), keyEquivalent: "")
                edit.target = self; edit.representedObject = node
                let del = menu.addItem(withTitle: "Delete channel…", action: #selector(channelContextDelete), keyEquivalent: "")
                del.target = self; del.representedObject = node
                menu.addItem(.separator())
            }
        }
        if ownPermissions.canCreateTempChannel || ownPermissions.isAdmin {
            menu.addItem(withTitle: "Create channel…", action: #selector(channelContextCreate), keyEquivalent: "").target = self
        }
    }

    private func buildUserContextMenu(_ menu: NSMenu) {
        let row = userTableView.clickedRow
        guard row >= 0, row < displayedUsers.count else { return }
        let user = displayedUsers[row]
        let isSelf = user.id == selfUserId

        menu.addItem(withTitle: "Adjust volume and noise settings…",
                     action: #selector(userContextTune), keyEquivalent: "").target = self

        if !isSelf {
            menu.addItem(.separator())
            menu.addItem(withTitle: "Send Private Message…",
                         action: #selector(userContextPm), keyEquivalent: "").target = self
            menu.addItem(.separator())
            if ownPermissions.canMoveUsers || ownPermissions.isAdmin {
                menu.addItem(withTitle: "Move to channel…", action: #selector(userContextMove), keyEquivalent: "").target = self
            }
            if ownPermissions.canKick || ownPermissions.isAdmin {
                menu.addItem(withTitle: "Kick…", action: #selector(userContextKick), keyEquivalent: "").target = self
            }
            if ownPermissions.canBan || ownPermissions.isAdmin {
                menu.addItem(withTitle: "Ban…", action: #selector(userContextBan), keyEquivalent: "").target = self
            }
            if ownPermissions.isAdmin {
                menu.addItem(.separator())
                menu.addItem(withTitle: user.serverMuted ? "Server unmute" : "Server mute",
                             action: #selector(userContextServerMute), keyEquivalent: "").target = self
                menu.addItem(withTitle: user.serverDeafened ? "Server undeafen" : "Server deafen",
                             action: #selector(userContextServerDeafen), keyEquivalent: "").target = self
                menu.addItem(withTitle: "Set permissions…", action: #selector(userContextPermissions), keyEquivalent: "").target = self
            }
        }
    }

    @objc private func channelContextJoin() {
        guard let node = channelOutlineView.item(atRow: channelOutlineView.clickedRow) as? ChannelNode else { return }
        joinChannelRequest(node.channel.id)
    }

    @objc private func channelContextCreate() {
        let sheet = ChannelEditSheet(channels: channels, editing: nil)
        sheet.onComplete = { [weak self] info in
            guard let info else { return }
            self?.client.createChannel(info)
        }
        presentSheet(sheet)
    }

    @objc private func channelContextEdit(_ sender: NSMenuItem) {
        guard let node = sender.representedObject as? ChannelNode else { return }
        let ch = node.channel
        let info = ChannelEdit(id: ch.id, parentId: ch.parentId, name: ch.name,
                               topic: ch.topic, passwordProtected: ch.passwordProtected,
                               password: nil, maxUsers: ch.maxUsers,
                               sortOrder: 0, audio: AudioConfig())
        let sheet = ChannelEditSheet(channels: channels, editing: info)
        sheet.onComplete = { [weak self] edited in
            guard let edited else { return }
            self?.client.editChannel(edited)
        }
        presentSheet(sheet)
    }

    @objc private func channelContextDelete(_ sender: NSMenuItem) {
        guard let node = sender.representedObject as? ChannelNode else { return }
        let alert = NSAlert()
        alert.messageText = "Delete channel?"
        alert.informativeText = "Delete \"\(node.channel.name)\"? This cannot be undone."
        alert.addButton(withTitle: "Delete"); alert.addButton(withTitle: "Cancel")
        alert.alertStyle = .warning
        guard let window else { return }
        alert.beginSheetModal(for: window) { [weak self] response in
            if response == .alertFirstButtonReturn {
                self?.client.deleteChannel(node.channel.id)
            }
        }
    }

    @objc private func userContextTune() { openUserTuning(row: userTableView.clickedRow) }
    @objc private func userContextPm() {
        guard let u = selectedUserAt(userTableView.clickedRow) else { return }
        openPmWindow(u)
    }
    @objc private func userContextMove() {
        guard let u = selectedUserAt(userTableView.clickedRow) else { return }
        moveUser(u)
    }
    @objc private func userContextKick() {
        guard let u = selectedUserAt(userTableView.clickedRow) else { return }
        kickUser(u)
    }
    @objc private func userContextBan() {
        guard let u = selectedUserAt(userTableView.clickedRow) else { return }
        banUser(u)
    }
    @objc private func userContextServerMute() {
        guard let u = selectedUserAt(userTableView.clickedRow) else { return }
        toggleServerMute(u)
    }
    @objc private func userContextServerDeafen() {
        guard let u = selectedUserAt(userTableView.clickedRow) else { return }
        toggleServerDeafen(u)
    }
    @objc private func userContextPermissions() {
        guard let u = selectedUserAt(userTableView.clickedRow) else { return }
        setPermissions(u)
    }

    private func selectedUserAt(_ row: Int) -> User? {
        guard row >= 0, row < displayedUsers.count else { return nil }
        return displayedUsers[row]
    }
}

// MARK: - PTT key name helper

func keyCodeName(_ keyCode: UInt16) -> String {
    let map: [UInt16: String] = [
        0x60: "F5", 0x61: "F6", 0x62: "F7", 0x63: "F3", 0x64: "F8", 0x65: "F9",
        0x67: "F11", 0x69: "F13", 0x6A: "F16", 0x6B: "F14", 0x6D: "F10", 0x6F: "F12",
        0x71: "F15", 0x72: "Help", 0x73: "Home", 0x74: "PgUp", 0x75: "Del",
        0x76: "F4", 0x77: "End", 0x78: "F2", 0x79: "PgDn", 0x7A: "F1",
    ]
    return map[keyCode] ?? "Key\(keyCode)"
}

// MARK: - NSToolbarDelegate

extension MainWindowController: NSToolbarDelegate {
    func toolbarDefaultItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] {
        [ToolbarID.joinVoice, ToolbarID.shareScreen, .space,
         ToolbarID.mute, ToolbarID.deafen, .flexibleSpace,
         ToolbarID.outputVolume]
    }

    func toolbarAllowedItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] {
        toolbarDefaultItemIdentifiers(toolbar)
    }

    func toolbar(_ toolbar: NSToolbar,
                 itemForItemIdentifier itemIdentifier: NSToolbarItem.Identifier,
                 willBeInsertedIntoToolbar flag: Bool) -> NSToolbarItem? {
        let item = NSToolbarItem(itemIdentifier: itemIdentifier)

        switch itemIdentifier {
        case ToolbarID.joinVoice:
            let btn = makeToolbarButton(symbol: "mic", label: "Join Voice",
                                        action: #selector(micToggleClicked),
                                        accessibilityLabel: "Join Voice — start sending microphone audio")
            item.view = btn
            item.label = "Join Voice"
            item.paletteLabel = "Join Voice"
            item.toolTip = "Join or leave voice (⌘⇧V)"
            joinVoiceButton = btn
            joinVoiceItem = item

        case ToolbarID.shareScreen:
            let btn = makeToolbarButton(symbol: "rectangle.on.rectangle.angled", label: "Share Screen Audio",
                                        action: #selector(screenAudioClicked),
                                        accessibilityLabel: "Share Screen Audio")
            item.view = btn
            item.label = "Share Screen Audio"
            item.paletteLabel = "Share Screen Audio"
            item.toolTip = "Share screen audio (⌘⇧S)"
            shareScreenButton = btn
            shareScreenItem = item

        case ToolbarID.mute:
            let btn = makeToolbarButton(symbol: "mic.fill", label: "Mute",
                                        action: #selector(muteClicked),
                                        accessibilityLabel: "Mute microphone")
            item.view = btn
            item.label = "Mute"
            item.paletteLabel = "Mute"
            item.toolTip = "Mute/unmute mic (⌘⇧M)"
            btn.isEnabled = false
            muteButton = btn

        case ToolbarID.deafen:
            let btn = makeToolbarButton(symbol: "speaker.wave.2.fill", label: "Deafen",
                                        action: #selector(deafenClicked),
                                        accessibilityLabel: "Deafen — mute all incoming audio")
            item.view = btn
            item.label = "Deafen"
            item.paletteLabel = "Deafen"
            item.toolTip = "Deafen/undeafen (⌘⇧D)"
            btn.isEnabled = false
            deafenButton = btn

        case ToolbarID.outputVolume:
            let slider = NSSlider(value: 80, minValue: 0, maxValue: 100, target: self,
                                  action: #selector(outputVolumeSliderChanged))
            slider.numberOfTickMarks = 0
            slider.setAccessibilityLabel("Output volume")
            slider.setAccessibilityHelp("Global playback volume for all incoming audio")
            let container = NSView(frame: NSRect(x: 0, y: 0, width: 140, height: 24))
            slider.translatesAutoresizingMaskIntoConstraints = false
            container.addSubview(slider)
            NSLayoutConstraint.activate([
                slider.leadingAnchor.constraint(equalTo: container.leadingAnchor),
                slider.trailingAnchor.constraint(equalTo: container.trailingAnchor),
                slider.centerYAnchor.constraint(equalTo: container.centerYAnchor),
            ])
            item.view = container
            item.label = "Output Volume"
            item.paletteLabel = "Output Volume"
            item.toolTip = "Global output volume"
            outputVolumeSlider = slider

        default:
            return nil
        }

        return item
    }
}
