using VoiceCat.App.Audio;
using VoiceCat.App.Models;
using VoiceCat.App.Notifications;
using VoiceCat.Interop;

namespace VoiceCat.App.Forms;

/// <summary>
/// Post-auth main window. Owns the VoiceCatClient for its entire lifetime.
/// </summary>
public partial class MainForm : Form
{
    private readonly VoiceCatClient _client;
    private readonly uint _selfUserId;
    private readonly string _nickname;
    private readonly System.Windows.Forms.Timer _pumpTimer = new() { Interval = 30 };
    private readonly EventFeedback _feedback = new(FeedbackSettings.Load());
    private readonly VoiceSettings _voiceSettings = VoiceSettings.Load();

    // Channel / user state
    private uint _currentChannelId;
    private List<ChannelInfo> _channels = [];
    private readonly Dictionary<uint, UserInfo> _users = [];
    private readonly HashSet<uint> _talkingUsers = [];
    private PermissionsInfo _ownPermissions = new(false, false, false, false, false, false);

    // Voice state
    private uint _micStreamId; // 0 = not started
    private uint _screenStreamId; // 0 = not sharing screen audio
    private ProcessAudioMixer? _screenMixer; // non-null only in per-app capture mode
    private uint _auxStreamId; // 0 = aux (second input device) stream not active
    private InputDeviceCapture? _auxCapture; // client-side capture feeding the aux stream
    private Keys _pttKey = Keys.F8;
    private bool _pttEngaged; // guards the PTT cue against key-repeat
    private bool _serverMuted;
    private bool _serverDeafened;

    // Private message windows keyed by the other user's ID
    private readonly Dictionary<uint, PrivateMessageForm> _pmWindows = [];

    // Voice menu items (kept as fields so we can update their text/state)
    private ToolStripMenuItem _miJoinVoice = null!;
    private ToolStripMenuItem _miScreenShare = null!;

    public MainForm(VoiceCatClient client, uint selfUserId, string nickname, string serverName)
    {
        InitializeComponent();
        _client = client;
        _selfUserId = selfUserId;
        _nickname = nickname;

        Text = string.IsNullOrWhiteSpace(serverName)
            ? $"VoiceCat — {nickname}"
            : $"VoiceCat — {nickname} @ {serverName}";

        _client.EventReceived += OnEvent;
        _client.LevelChanged += OnLevelChanged;
        _pumpTimer.Tick += (_, _) => _client.PumpEvents();

        _ownPermissions = SafeGetPermissions();
        BuildMenus();
        BuildChannelContextMenu();
        BuildUserContextMenu();

        _pumpTimer.Start();

        // Apply initial output volume
        _client.SetOutputVolume(trkOutputVolume.Value / 100f);

        // Channel tree
        tvChannels.DoubleClick += TvChannels_DoubleClick;
        tvChannels.KeyDown += TvChannels_KeyDown;
        lstUsers.DoubleClick += (_, _) => OpenUserTuning();
        lstUsers.KeyDown += (_, e) => { if (e.KeyCode == Keys.Enter) { OpenUserTuning(); e.Handled = e.SuppressKeyPress = true; } };

        // Compose
        txtCompose.KeyDown += TxtCompose_KeyDown;
        btnSend.Click += (_, _) => SendText();

        // Toolbar voice buttons
        tsbJoinVoice.Click += BtnMicToggle_Click;
        tsbScreenShare.Click += BtnScreenShareToggle_Click;

        // Output volume slider
        trkOutputVolume.Scroll += TrkOutputVolume_Scroll;

        // Voice controls
        chkMute.CheckedChanged += (_, _) => ApplySelfMute();
        chkDeafen.CheckedChanged += (_, _) => ApplySelfMute();

        // PTT and global hotkeys (focus-scoped — work only while this form has focus)
        KeyDown += MainForm_KeyDown;
        KeyDown += MainForm_HotkeyDown;
        KeyUp += MainForm_KeyUp;
        Deactivate += (_, _) =>
        {
            if (_micStreamId != 0) _client.SetPushToTalk(false);
        };

        ApplyPersistedVoiceSettings();
        BootstrapFromServer();
    }

    private void ApplyPersistedVoiceSettings() =>
        _pttKey = (Keys)_voiceSettings.PttKey;

    // ── Startup ──────────────────────────────────────────────────────────────

    private void BootstrapFromServer()
    {
        _channels = _client.ListChannels();
        var users = _client.ListUsers();
        _users.Clear();
        foreach (var u in users)
        {
            _users[u.Id] = u;
            if (u.Id == _selfUserId)
            {
                _currentChannelId = u.ChannelId;
                UpdateSelfServerMuteState(u.ServerMuted, u.ServerDeafened);
            }
        }
        RefreshChannelTree();
        RefreshUserList();
        UpdateStatusLabel();
        AddActivity($"Connected to server as {_nickname}");
        _feedback.PlaySound(SoundEvent.Login);
        _feedback.Speak("Connected");
    }

    protected override void OnLoad(EventArgs e)
    {
        base.OnLoad(e);
        splitMain.SplitterDistance = Math.Min(220, splitMain.Width - 304);
        splitLeft.SplitterDistance = Math.Min(260, splitLeft.Height - 84);
    }

    // ── Menu / context menu builders ─────────────────────────────────────────

    private PermissionsInfo SafeGetPermissions()
    {
        try { return _client.GetPermissions(); }
        catch { return new PermissionsInfo(false, false, false, false, false, false); }
    }

    private void BuildMenus()
    {
        // Voice menu — always visible
        var voiceMenu = new ToolStripMenuItem("&Voice");

        _miJoinVoice = new ToolStripMenuItem("&Join Voice");
        _miJoinVoice.Click += BtnMicToggle_Click;
        _miJoinVoice.ShortcutKeyDisplayString = "Ctrl+Shift+V";
        voiceMenu.DropDownItems.Add(_miJoinVoice);

        _miScreenShare = new ToolStripMenuItem("Share Screen &Audio");
        _miScreenShare.Click += BtnScreenShareToggle_Click;
        _miScreenShare.ShortcutKeyDisplayString = "Ctrl+Shift+S";
        voiceMenu.DropDownItems.Add(_miScreenShare);

        menuStrip.Items.Add(voiceMenu);

        // Messages menu — always visible
        var messagesMenu = new ToolStripMenuItem("&Messages");
        var miNewPm = new ToolStripMenuItem("&New Private Message...");
        miNewPm.ShortcutKeys = Keys.Control | Keys.P;
        miNewPm.Click += (_, _) => OpenNewPmDialog();
        messagesMenu.DropDownItems.Add(miNewPm);
        menuStrip.Items.Add(messagesMenu);

        // Settings menu — always visible
        var settingsMenu = new ToolStripMenuItem("&Settings");
        var miAudio = new ToolStripMenuItem("&Audio...");
        miAudio.Click += (_, _) =>
        {
            using var dlg = new AudioSettingsForm(_client, _voiceSettings, _micStreamId,
                applyAuxEnabled: on =>
                {
                    if (_micStreamId == 0) return; // not in voice — applied on next Join Voice
                    if (on) StartAuxStream(); else StopAuxStream();
                },
                applyAuxDevice: _ =>
                {
                    if (_auxStreamId != 0) RestartAuxCapture(); // settings.AuxDeviceId already updated
                });
            dlg.ShowDialog(this);
            _pttKey = (Keys)_voiceSettings.PttKey;
        };
        settingsMenu.DropDownItems.Add(miAudio);
        var miNotifications = new ToolStripMenuItem("&Notifications...");
        miNotifications.Click += (_, _) =>
        {
            using var dlg = new NotificationSettingsForm(_feedback);
            dlg.ShowDialog(this);
        };
        settingsMenu.DropDownItems.Add(miNotifications);
        menuStrip.Items.Add(settingsMenu);

        // Admin menu — only if permitted
        if (_ownPermissions.CanAdminAccounts)
        {
            var adminMenu = new ToolStripMenuItem("&Admin");
            var miAccounts = new ToolStripMenuItem("&Server accounts...");
            miAccounts.Click += (_, _) =>
            {
                using var dlg = new AccountsDialog(_client);
                dlg.ShowDialog(this);
            };
            adminMenu.DropDownItems.Add(miAccounts);
            menuStrip.Items.Add(adminMenu);
        }
    }

    private void BuildChannelContextMenu()
    {
        var ctx = new ContextMenuStrip();
        ctx.Opening += (_, _) =>
        {
            ctx.Items.Clear();
            bool hasSelection = tvChannels.SelectedNode?.Tag is uint;
            bool canCreate = _ownPermissions.CanCreateTempChannel || _ownPermissions.IsAdmin;
            bool isAdmin = _ownPermissions.IsAdmin;

            if (hasSelection)
            {
                ctx.Items.Add("&Join", null, (_, _) => ChannelTreeJoinSelected());
                ctx.Items.Add(new ToolStripSeparator());
            }

            if (canCreate)
                ctx.Items.Add("&Create channel...", null, (_, _) => CreateChannel());

            if (hasSelection && isAdmin)
            {
                ctx.Items.Add("&Edit channel...", null, (_, _) => EditSelectedChannel());
                ctx.Items.Add("&Delete channel...", null, (_, _) => DeleteSelectedChannel());
            }
        };
        ctx.Opened += (_, _) => SelectFirstMenuItem(ctx);
        tvChannels.ContextMenuStrip = ctx;
    }

    private void BuildUserContextMenu()
    {
        var ctx = new ContextMenuStrip();
        ctx.Opening += (_, _) =>
        {
            ctx.Items.Clear();
            if (lstUsers.SelectedItem is not UserListItem item) return;
            if (!_users.TryGetValue(item.UserId, out var user)) return;
            bool isSelf = user.Id == _selfUserId;

            var miTune = new ToolStripMenuItem("&Adjust volume and noise settings...");
            miTune.Click += (_, _) => OpenUserTuning();
            ctx.Items.Add(miTune);

            if (!isSelf)
            {
                ctx.Items.Add(new ToolStripSeparator());

                var miPm = new ToolStripMenuItem("Send &Private Message");
                miPm.Click += (_, _) => OpenPmWindow(user.Id);
                ctx.Items.Add(miPm);

                ctx.Items.Add(new ToolStripSeparator());

                if (_ownPermissions.CanMoveUsers || _ownPermissions.IsAdmin)
                    ctx.Items.Add("&Move to channel...", null, (_, _) => MoveSelectedUser());
                if (_ownPermissions.CanKick || _ownPermissions.IsAdmin)
                    ctx.Items.Add("&Kick...", null, (_, _) => KickSelectedUser());
                if (_ownPermissions.CanBan || _ownPermissions.IsAdmin)
                    ctx.Items.Add("&Ban...", null, (_, _) => BanSelectedUser());

                ctx.Items.Add(new ToolStripSeparator());

                if (_ownPermissions.IsAdmin)
                {
                    ctx.Items.Add(user.ServerMuted ? "Server &unmute" : "Server &mute",
                        null, (_, _) => ToggleServerMuteSelected());
                    ctx.Items.Add(user.ServerDeafened ? "Server un&deafen" : "Server &deafen",
                        null, (_, _) => ToggleServerDeafenSelected());
                    ctx.Items.Add("&Set permissions...", null, (_, _) => SetPermissionsSelected());
                }
            }
        };
        ctx.Opened += (_, _) => SelectFirstMenuItem(ctx);
        lstUsers.ContextMenuStrip = ctx;
    }

    // Work around the WinForms ContextMenuStrip accessibility bug: when opened by
    // keyboard (Shift+F10 / Apps key) the focused item is not set, so screen readers
    // stay silent until the first arrow key. Selecting the first item ourselves on
    // Opened raises the UIA focus event immediately.
    private static void SelectFirstMenuItem(ContextMenuStrip ctx)
    {
        foreach (ToolStripItem item in ctx.Items)
        {
            if (item is ToolStripMenuItem && item.Enabled)
            {
                item.Select();
                break;
            }
        }
    }

    // ── Event dispatch ────────────────────────────────────────────────────────

    private void OnEvent(VoiceCatEvent ev)
    {
        switch (ev.Type)
        {
            case VcEventType.ChannelList:
                HandleChannelList();
                break;
            case VcEventType.UserJoined:
                HandleUserJoined(ev);
                break;
            case VcEventType.UserLeft:
                HandleUserLeft(ev);
                break;
            case VcEventType.UserUpdated:
                HandleUserUpdated();
                break;
            case VcEventType.JoinResult:
                HandleJoinResult(ev);
                break;
            case VcEventType.GenericResult:
                HandleGenericResult(ev);
                break;
            case VcEventType.AccountList:
                AddActivity("Account list updated");
                break;
            case VcEventType.TextMessage:
                HandleTextMessage(ev);
                break;
            case VcEventType.TalkState:
                HandleTalkState(ev);
                break;
            case VcEventType.StreamStarted:
                HandleStreamStarted(ev);
                break;
            case VcEventType.StreamStopped:
                if (_users.TryGetValue(ev.UserId, out var stUser) &&
                    stUser.ChannelId == _currentChannelId)
                    AddActivity($"{stUser.Nickname} stopped a stream");
                break;
            case VcEventType.Disconnected:
                HandleDisconnected(ev);
                break;
        }
    }

    // ── Event handlers ────────────────────────────────────────────────────────

    private void HandleChannelList()
    {
        _channels = _client.ListChannels();
        var users = _client.ListUsers();
        _users.Clear();
        foreach (var u in users)
        {
            _users[u.Id] = u;
            if (u.Id == _selfUserId) _currentChannelId = u.ChannelId;
        }
        RefreshChannelTree();
        RefreshUserList();
    }

    private void HandleUserJoined(VoiceCatEvent ev)
    {
        var user = new UserInfo(ev.UserId, ev.Text ?? $"User#{ev.UserId}", false, ev.ChannelId,
            false, false, false, false);
        _users[ev.UserId] = user;
        RefreshChannelTree();
        RefreshUserList();
        if (ev.ChannelId == _currentChannelId && ev.UserId != _selfUserId)
        {
            AddActivity($"{user.Nickname} joined the channel");
            _feedback.PlaySound(SoundEvent.ChannelJoin);
            _feedback.Speak($"{user.Nickname} joined");
        }
    }

    private void HandleUserLeft(VoiceCatEvent ev)
    {
        if (!_users.TryGetValue(ev.UserId, out var user)) return;
        bool wasHere = user.ChannelId == _currentChannelId && ev.UserId != _selfUserId;
        _users.Remove(ev.UserId);
        _talkingUsers.Remove(ev.UserId);
        RefreshChannelTree();
        RefreshUserList();
        if (wasHere)
        {
            AddActivity($"{user.Nickname} left the channel");
            _feedback.PlaySound(SoundEvent.ChannelLeave);
            _feedback.Speak($"{user.Nickname} left");
        }
        if (_pmWindows.TryGetValue(ev.UserId, out var pmWin))
            pmWin.AppendActivity($"{user.Nickname} disconnected from server");
    }

    private void HandleUserUpdated()
    {
        var users = _client.ListUsers();
        _users.Clear();
        foreach (var u in users)
        {
            _users[u.Id] = u;
            if (u.Id == _selfUserId)
            {
                _currentChannelId = u.ChannelId;
                UpdateSelfServerMuteState(u.ServerMuted, u.ServerDeafened);
            }
        }
        RefreshChannelTree();
        RefreshUserList();
    }

    private void HandleJoinResult(VoiceCatEvent ev)
    {
        if (ev.Result == VcResult.Ok)
        {
            _currentChannelId = ev.ChannelId;
            if (_users.TryGetValue(_selfUserId, out var self))
                _users[_selfUserId] = self with { ChannelId = ev.ChannelId };
            RefreshChannelTree();
            RefreshUserList();
            UpdateStatusLabel();
            string chanName = _channels.FirstOrDefault(c => c.Id == ev.ChannelId)?.Name
                              ?? $"Channel #{ev.ChannelId}";
            AddActivity($"Joined {chanName}");
        }
        else
        {
            AddActivity($"Could not join channel: {ev.Text ?? ev.Result.ToString()}");
        }
    }

    private void HandleGenericResult(VoiceCatEvent ev)
    {
        string prefix = ev.Result == VcResult.Ok ? "Success" : "Failed";
        string detail = !string.IsNullOrEmpty(ev.Text) ? $": {ev.Text}" : "";
        AddActivity($"{prefix}{detail} ({ev.Result})");
    }

    private void HandleTextMessage(VoiceCatEvent ev)
    {
        string time = ev.TimestampUnixMs > 0
            ? DateTimeOffset.FromUnixTimeMilliseconds((long)ev.TimestampUnixMs)
                           .LocalDateTime.ToString("HH:mm")
            : DateTime.Now.ToString("HH:mm");
        string sender = GetNickname(ev.UserId);
        bool isSelf = ev.UserId == _selfUserId;
        string body = ev.Text ?? "";

        if (ev.TextScope == VcTextScope.Private)
        {
            // For our own outgoing PM, ev.ChannelId carries the recipient user ID.
            uint otherUserId = isSelf ? ev.ChannelId : ev.UserId;
            var win = GetOrOpenPmWindow(otherUserId);
            win.AppendMessage(time, isSelf, sender, body);
            _feedback.PlaySound(isSelf ? SoundEvent.PmSent : SoundEvent.PmRecv);
            if (!isSelf) _feedback.Speak($"Private message from {sender}: {body}");
        }
        else
        {
            AppendChat(time, sender, body);
            _feedback.PlaySound(isSelf ? SoundEvent.ChannelSent : SoundEvent.ChannelRecv);
            if (!isSelf) _feedback.Speak($"{sender}: {body}");
        }
    }

    private void HandleTalkState(VoiceCatEvent ev)
    {
        bool talking = ev.U32a == 1;
        if (talking) _talkingUsers.Add(ev.UserId);
        else _talkingUsers.Remove(ev.UserId);
        RefreshUserList();
        if (ev.UserId == _selfUserId)
            _feedback.PlaySound(talking ? SoundEvent.VaStart : SoundEvent.VaStop);
        if (talking && ev.UserId != _selfUserId &&
            _users.TryGetValue(ev.UserId, out var tUser) &&
            tUser.ChannelId == _currentChannelId)
            AddActivity($"{tUser.Nickname} started talking");
    }

    private void HandleStreamStarted(VoiceCatEvent ev)
    {
        if (!_users.TryGetValue(ev.UserId, out var sUser) ||
            sUser.ChannelId != _currentChannelId) return;
        var streams = _client.ListUserStreams(ev.UserId);
        var stream = streams.FirstOrDefault(s => s.StreamId == ev.StreamId);
        string kind = stream?.Kind switch
        {
            VcStreamKind.ScreenAudio => "screen audio",
            VcStreamKind.AuxDevice => "aux device",
            _ => "microphone",
        };
        AddActivity($"{sUser.Nickname} started {kind} stream");
    }

    private void HandleDisconnected(VoiceCatEvent ev)
    {
        string msg = string.IsNullOrEmpty(ev.Text)
            ? "Disconnected from server."
            : $"Disconnected: {ev.Text}";
        lblStatus.Text = msg;
        AddActivity(msg);
        if (ev.Result == VcResult.Ok)
        {
            _feedback.PlaySound(SoundEvent.Logout);
            _feedback.Speak("Disconnected");
        }
        else
        {
            _feedback.PlaySound(SoundEvent.ConnectionLost);
            _feedback.Speak("Connection lost");
        }
        tvChannels.Nodes.Clear();
        lstUsers.Items.Clear();
        _users.Clear();
        _talkingUsers.Clear();
        _currentChannelId = 0;
        _micStreamId = 0;
        _screenStreamId = 0;
        _screenMixer?.Stop(); _screenMixer?.Dispose(); _screenMixer = null;
        DisposeAuxCapture(); _auxStreamId = 0; // connection gone — drop capture, no StopStream
        txtCompose.Enabled = false;
        btnSend.Enabled = false;
        tsbJoinVoice.Enabled = false;
        tsbScreenShare.Enabled = false;
        _miJoinVoice.Enabled = false;
        _miScreenShare.Enabled = false;
        foreach (var win in _pmWindows.Values.ToList()) win.Close();
        _pmWindows.Clear();
    }

    // ── Level meter ───────────────────────────────────────────────────────────

    private void OnLevelChanged(uint streamId, float rms)
    {
        if (streamId == _micStreamId)
            pbLevel.Value = Math.Min(100, (int)(rms * 400));
    }

    // ── UI refresh helpers ────────────────────────────────────────────────────

    private void RefreshChannelTree()
    {
        uint toSelect = tvChannels.SelectedNode?.Tag is uint s ? s : _currentChannelId;
        bool hadFocus = tvChannels.Focused;
        tvChannels.BeginUpdate();
        tvChannels.Nodes.Clear();

        var byParent = _channels
            .GroupBy(c => c.ParentId)
            .ToDictionary(g => g.Key, g => g.ToList());

        void AddChildren(TreeNodeCollection nodes, uint parentId)
        {
            if (!byParent.TryGetValue(parentId, out var kids)) return;
            foreach (var ch in kids.OrderBy(c => c.Name))
            {
                int count = _users.Values.Count(u => u.ChannelId == ch.Id);
                var label = $"{ch.Name} ({count})";
                if (ch.PasswordProtected) label += " [password]";
                if (ch.Id == _currentChannelId) label += " ►";
                var node = new TreeNode(label) { Tag = ch.Id };
                nodes.Add(node);
                AddChildren(node.Nodes, ch.Id);
            }
        }
        AddChildren(tvChannels.Nodes, 0);
        tvChannels.ExpandAll();
        SeekAndSelect(tvChannels.Nodes, toSelect);
        tvChannels.EndUpdate();

        // A Nodes.Clear()/rebuild can drop keyboard focus and leave the screen reader
        // without a current node. If the tree was focused before the refresh, restore
        // focus and re-announce the now-current node (null-then-reselect forces UIA to
        // fire a fresh focus event).
        if (hadFocus && tvChannels.SelectedNode != null)
        {
            var node = tvChannels.SelectedNode;
            tvChannels.Focus();
            tvChannels.SelectedNode = null;
            tvChannels.SelectedNode = node;
        }
    }

    private bool SeekAndSelect(TreeNodeCollection nodes, uint channelId)
    {
        if (channelId == 0) return false;
        foreach (TreeNode n in nodes)
        {
            if (n.Tag is uint id && id == channelId) { tvChannels.SelectedNode = n; return true; }
            if (SeekAndSelect(n.Nodes, channelId)) return true;
        }
        return false;
    }

    private void RefreshUserList()
    {
        // Preserve the keyboard selection across the rebuild: clearing the list resets
        // SelectedIndex to -1, which would throw focus around every time a talking/mute
        // indicator toggles. Capture the selected user id and reselect it afterwards.
        uint? prevSel = (lstUsers.SelectedItem as UserListItem)?.UserId;

        lstUsers.BeginUpdate();
        lstUsers.Items.Clear();
        foreach (var user in _users.Values
            .Where(u => u.ChannelId == _currentChannelId)
            .OrderBy(u => u.Nickname))
        {
            string 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)";
            lstUsers.Items.Add(new UserListItem(user.Id, label));
        }
        if (prevSel is uint sel)
        {
            for (int i = 0; i < lstUsers.Items.Count; i++)
                if (lstUsers.Items[i] is UserListItem item && item.UserId == sel)
                {
                    lstUsers.SelectedIndex = i;
                    break;
                }
        }
        lstUsers.EndUpdate();
        UpdateStatusLabel();
    }

    private void UpdateStatusLabel()
    {
        string suffix = "";
        if (_serverMuted) suffix += " [server muted]";
        if (_serverDeafened) suffix += " [server deafened]";

        if (_currentChannelId == 0)
        {
            lblStatus.Text = $"Connected as {_nickname}{suffix} — not in a channel.";
            return;
        }
        string chanName = _channels.FirstOrDefault(c => c.Id == _currentChannelId)?.Name
                          ?? $"Channel #{_currentChannelId}";
        int count = _users.Values.Count(u => u.ChannelId == _currentChannelId);
        lblStatus.Text = $"Connected as {_nickname}{suffix} — {chanName} ({count} user{(count == 1 ? "" : "s")})";
    }

    // ── Voice controls ────────────────────────────────────────────────────────

    private void BtnMicToggle_Click(object? sender, EventArgs e)
    {
        if (_micStreamId == 0)
        {
            var (result, streamId) = _client.StartStream(VcStreamKind.Mic, "Microphone");
            if (result == VcResult.Ok)
            {
                _micStreamId = streamId;
                var mode = (VcInputMode)_voiceSettings.InputMode;
                if (_voiceSettings.InputDeviceId is string devId)
                    _client.SetInputDevice(streamId, devId);
                _client.SetCaptureChannels(streamId, _voiceSettings.StereoMic ? 2u : 1u);
                _client.SetInputMode(mode);
                if (mode == VcInputMode.VoiceActivation)
                    _client.SetVadThreshold(VadThresholdFromSettings());
                _client.SetInputGain(_voiceSettings.MicGain / 100f);
                _client.SetInputNoiseReduction(_voiceSettings.MicNoiseReduction);
                SetVoiceJoinedState(true);
                AddActivity("Joined voice — microphone active");
                _feedback.PlaySound(SoundEvent.VoiceOn);
                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;
            pbLevel.Value = 0;
            SetVoiceJoinedState(false);
            AddActivity("Left voice");
            _feedback.PlaySound(SoundEvent.VoiceOff);
        }
    }

    private void SetVoiceJoinedState(bool joined)
    {
        tsbJoinVoice.Text = joined ? "Leave Voice" : "Join Voice";
        _miJoinVoice.Text = joined ? "Leave &Voice" : "&Join Voice";
        chkMute.Enabled = joined;
        chkDeafen.Enabled = joined;
    }

    private void ApplySelfMute() =>
        _client.SetSelfMute(chkMute.Checked, chkDeafen.Checked);

    private void BtnScreenShareToggle_Click(object? sender, EventArgs e)
    {
        if (_screenStreamId == 0)
        {
            StartScreenAudio();
        }
        else
        {
            StopScreenAudio();
        }
    }

    private void StartScreenAudio()
    {
        using var picker = new AppAudioPickerDialog();
        if (picker.ShowDialog(this) != DialogResult.OK || picker.ChosenScope == null) return;

        var scope = picker.ChosenScope;

        if (scope is EntireDesktop { ExcludeSelf: false })
        {
            // Existing whole-device WASAPI loopback path — core handles it.
            var (result, streamId) = _client.StartStream(VcStreamKind.ScreenAudio, "Desktop audio");
            if (result != VcResult.Ok)
            {
                AddActivity($"Failed to start screen audio: {result}");
                return;
            }
            _screenStreamId = streamId;
            AddActivity("Sharing screen audio: entire desktop");
        }
        else
        {
            // External-feed path: suppress core loopback, C# mixer feeds PCM. Covers the
            // per-app modes and "entire desktop except VoiceCat" (single EXCLUDE of self).
            var (result, streamId) = _client.StartStreamExternalFeed(VcStreamKind.ScreenAudio, "App audio");
            if (result != VcResult.Ok)
            {
                AddActivity($"Failed to start screen audio: {result}");
                return;
            }
            _screenStreamId = streamId;

            _screenMixer = new ProcessAudioMixer();
            _screenMixer.Start(scope, _client, streamId);

            string desc = scope switch
            {
                EntireDesktop => "entire desktop (excluding VoiceCat)",
                OnlyApps    o => $"only {o.Pids.Count} app(s)",
                AllExceptApps a => $"all except {a.Names[0]}",
                _ => "apps",
            };
            AddActivity($"Sharing screen audio: {desc}");
        }

        tsbScreenShare.Text = "Stop Screen Audio";
        _miScreenShare.Text = "Stop Screen &Audio";
    }

    private void StopScreenAudio()
    {
        _screenMixer?.Stop();
        _screenMixer?.Dispose();
        _screenMixer = null;

        _client.StopStream(_screenStreamId);
        _screenStreamId = 0;
        tsbScreenShare.Text = "Share Screen Audio";
        _miScreenShare.Text = "Share Screen &Audio";
        AddActivity("Stopped sharing screen audio");
    }

    // ── Aux input stream (second hardware input device) ─────────────────────────
    // A second outgoing stream (kind = AUX_DEVICE, external_feed). The core can't open a second
    // capture device, so we capture the chosen device here and feed PCM in — the same external-
    // feed pipeline as per-app screen audio. Tied to the voice session: started on Join Voice
    // (when enabled) and stopped on Leave Voice. The aux is always-on (the core never gates
    // AUX_DEVICE on VAD/PTT); volume is applied client-side before feeding.

    private void StartAuxStream()
    {
        if (_auxStreamId != 0 || !_voiceSettings.AuxEnabled) return;

        var (result, streamId) = _client.StartStreamExternalFeed(VcStreamKind.AuxDevice, "Aux device");
        if (result != VcResult.Ok)
        {
            AddActivity($"Failed to start aux stream: {result}");
            return;
        }
        _auxStreamId = streamId;

        _auxCapture = new InputDeviceCapture(_voiceSettings.AuxDeviceId);
        _auxCapture.PcmFrameReady += OnAuxPcmFrame;
        if (!_auxCapture.Start())
        {
            AddActivity("Failed to open aux input device");
            StopAuxStream();
            return;
        }
        AddActivity("Aux input stream active");
    }

    private void StopAuxStream()
    {
        DisposeAuxCapture();
        if (_auxStreamId != 0)
        {
            _client.StopStream(_auxStreamId);
            _auxStreamId = 0;
        }
    }

    // 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 void RestartAuxCapture()
    {
        if (_auxStreamId == 0) return;
        DisposeAuxCapture();
        _auxCapture = new InputDeviceCapture(_voiceSettings.AuxDeviceId);
        _auxCapture.PcmFrameReady += OnAuxPcmFrame;
        if (!_auxCapture.Start())
            AddActivity("Failed to open aux input device");
    }

    private void DisposeAuxCapture()
    {
        if (_auxCapture == null) return;
        _auxCapture.PcmFrameReady -= OnAuxPcmFrame;
        _auxCapture.Stop();
        _auxCapture.Dispose();
        _auxCapture = null;
    }

    // Fired on the capture thread. vc_stream_feed_pcm is thread-safe, so feed directly. Gain is
    // read live from settings each frame (so the volume slider takes effect immediately).
    private void OnAuxPcmFrame(short[] pcm, int samplesPerChannel, int channels)
    {
        if (_auxStreamId == 0) return;
        float gain = _voiceSettings.AuxGain / 100f;
        if (gain != 1f)
        {
            for (int i = 0; i < pcm.Length; i++)
                pcm[i] = (short)Math.Clamp((int)MathF.Round(pcm[i] * gain),
                    short.MinValue, short.MaxValue);
        }
        _client.StreamFeedPcm(_auxStreamId, pcm, samplesPerChannel, (uint)channels);
    }

    private void TrkOutputVolume_Scroll(object? sender, EventArgs e) =>
        _client.SetOutputVolume(trkOutputVolume.Value / 100f);

    private float VadThresholdFromSettings() =>
        0.1f * (1f - (_voiceSettings.VadThresholdSlider - 1f) / 99f);

    // ── PTT key handling (focus-scoped) ───────────────────────────────────────

    private void MainForm_KeyDown(object? sender, KeyEventArgs e)
    {
        if ((VcInputMode)_voiceSettings.InputMode != VcInputMode.PushToTalk) return;
        if (e.KeyCode != _pttKey || _micStreamId == 0) return;
        if (ActiveControl is TextBox or RichTextBox) return;
        _client.SetPushToTalk(true);
        if (!_pttEngaged) // first key-down only, not auto-repeat
        {
            _pttEngaged = true;
            _feedback.PlaySound(SoundEvent.Ptt);
        }
        e.Handled = e.SuppressKeyPress = true;
    }

    private void MainForm_HotkeyDown(object? sender, KeyEventArgs e)
    {
        if (!e.Control || !e.Shift) return;
        if (ActiveControl is TextBox or RichTextBox) return;
        switch (e.KeyCode)
        {
            case Keys.V:
                BtnMicToggle_Click(null, EventArgs.Empty);
                break;
            case Keys.S:
                BtnScreenShareToggle_Click(null, EventArgs.Empty);
                break;
            case Keys.M:
                chkMute.Checked = !chkMute.Checked;
                ApplySelfMute();
                break;
            case Keys.D:
                chkDeafen.Checked = !chkDeafen.Checked;
                ApplySelfMute();
                break;
            default:
                return;
        }
        // A handled hotkey: suppress the follow-on WM_CHAR so the focused control
        // (channel tree / user list) doesn't emit the system "ding".
        e.Handled = e.SuppressKeyPress = true;
    }

    private void MainForm_KeyUp(object? sender, KeyEventArgs e)
    {
        if ((VcInputMode)_voiceSettings.InputMode != VcInputMode.PushToTalk) return;
        if (e.KeyCode != _pttKey || _micStreamId == 0) return;
        _client.SetPushToTalk(false);
        _pttEngaged = false;
        e.Handled = e.SuppressKeyPress = true;
    }

    // ── Channel navigation ────────────────────────────────────────────────────

    private void TvChannels_DoubleClick(object? sender, EventArgs e)
    {
        if (tvChannels.SelectedNode?.Tag is uint channelId)
            JoinChannelRequest(channelId);
    }

    private void TvChannels_KeyDown(object? sender, KeyEventArgs e)
    {
        if (e.KeyCode == Keys.Enter && tvChannels.SelectedNode?.Tag is uint channelId)
        {
            JoinChannelRequest(channelId);
            e.Handled = e.SuppressKeyPress = true;
        }
    }

    private void JoinChannelRequest(uint channelId)
    {
        if (channelId == _currentChannelId) return;
        var channel = _channels.FirstOrDefault(c => c.Id == channelId);
        string? password = null;
        if (channel?.PasswordProtected == true)
        {
            using var dlg = new PasswordPromptDialog($"Password for channel \"{channel.Name}\":");
            if (dlg.ShowDialog(this) != DialogResult.OK) return;
            password = dlg.Password;
        }
        _client.JoinChannel(channelId, password);
    }

    private void ChannelTreeJoinSelected()
    {
        if (tvChannels.SelectedNode?.Tag is uint channelId)
            JoinChannelRequest(channelId);
    }

    // ── Private messaging ─────────────────────────────────────────────────────

    private PrivateMessageForm GetOrOpenPmWindow(uint userId)
    {
        if (!_pmWindows.TryGetValue(userId, out var win) || win.IsDisposed)
        {
            string nick = GetNickname(userId);
            win = new PrivateMessageForm(_client, userId, nick, _selfUserId);
            win.FormClosed += (_, _) => _pmWindows.Remove(userId);
            _pmWindows[userId] = win;
            // Show without an owner: an owned form is forced to stay above MainForm and pulls
            // focus back to itself, so the main window can't be worked in while a PM is open.
            // OnFormClosed already closes any open PM windows, so this doesn't leak.
            win.Show();
        }
        else
        {
            if (win.WindowState == FormWindowState.Minimized)
                win.WindowState = FormWindowState.Normal;
            // Raise it for this explicit user-initiated open without the owner-style focus trap.
            win.Activate();
        }
        return win;
    }

    private void OpenPmWindow(uint userId) => GetOrOpenPmWindow(userId);

    private void OpenNewPmDialog()
    {
        var others = _users.Values
            .Where(u => u.Id != _selfUserId)
            .OrderBy(u => u.Nickname)
            .ToList();

        if (others.Count == 0)
        {
            MessageBox.Show(this, "No other users are connected to the server.",
                "New Private Message", MessageBoxButtons.OK, MessageBoxIcon.Information);
            return;
        }

        using var dlg = new UserPickerDialog(others);
        if (dlg.ShowDialog(this) == DialogResult.OK)
            OpenPmWindow(dlg.SelectedUserId);
    }

    // ── M5: Moderation helpers ────────────────────────────────────────────────

    private void UpdateSelfServerMuteState(bool muted, bool deafened)
    {
        bool wasMuted = _serverMuted;
        bool wasDeafened = _serverDeafened;
        _serverMuted = muted;
        _serverDeafened = deafened;

        if (muted && !wasMuted) AddActivity("You have been server-muted");
        if (deafened && !wasDeafened) AddActivity("You have been server-deafened");
        if (!muted && wasMuted) AddActivity("Server mute cleared");
        if (!deafened && wasDeafened) AddActivity("Server deafen cleared");

        UpdateStatusLabel();
    }

    private void CreateChannel()
    {
        using var dlg = new ChannelEditDialog(_channels);
        if (dlg.ShowDialog(this) != DialogResult.OK || dlg.Result is null) return;
        _client.CreateChannel(dlg.Result);
    }

    private void EditSelectedChannel()
    {
        if (tvChannels.SelectedNode?.Tag is not uint channelId) return;
        var channel = _channels.FirstOrDefault(c => c.Id == channelId);
        if (channel is null) return;

        var editInfo = new ChannelEditInfo(
            channel.Id, channel.ParentId, channel.Name, channel.Topic,
            channel.PasswordProtected, null, channel.MaxUsers, 0,
            new AudioConfigInfo(0, false, 48000, 0, 20, 0, true, 0, false, 10, false));

        using var dlg = new ChannelEditDialog(_channels, editInfo);
        if (dlg.ShowDialog(this) != DialogResult.OK || dlg.Result is null) return;
        _client.EditChannel(dlg.Result);
    }

    private void DeleteSelectedChannel()
    {
        if (tvChannels.SelectedNode?.Tag is not uint channelId) return;
        var channel = _channels.FirstOrDefault(c => c.Id == channelId);
        if (channel is null) return;

        var confirm = MessageBox.Show(this, $"Delete channel \"{channel.Name}\"?",
            "VoiceCat", MessageBoxButtons.YesNo, MessageBoxIcon.Question);
        if (confirm != DialogResult.Yes) return;

        _client.DeleteChannel(channelId);
    }

    private UserInfo? SelectedUser()
    {
        if (lstUsers.SelectedItem is not UserListItem item) return null;
        _users.TryGetValue(item.UserId, out var user);
        return user;
    }

    private void MoveSelectedUser()
    {
        var user = SelectedUser();
        if (user is null) return;
        using var dlg = new MoveUserDialog(_channels, user.ChannelId);
        if (dlg.ShowDialog(this) != DialogResult.OK) return;
        _client.MoveUser(user.Id, dlg.SelectedChannelId);
    }

    private void KickSelectedUser()
    {
        var user = SelectedUser();
        if (user is null) return;
        using var dlg = new InputDialog("Kick user", "&Reason:", "Kicked by admin");
        string? reason = dlg.ShowDialog(this) == DialogResult.OK ? dlg.TextValue : null;
        _client.KickUser(user.Id, reason);
    }

    private void BanSelectedUser()
    {
        var user = SelectedUser();
        if (user is null) return;
        using var dlg = new BanUserDialog(user.Nickname);
        if (dlg.ShowDialog(this) != DialogResult.OK) return;
        _client.BanUser(user.Id, dlg.Reason, dlg.ExpiresUnixMs);
    }

    private void ToggleServerMuteSelected()
    {
        var user = SelectedUser();
        if (user is null) return;
        _client.SetServerMute(user.Id, !user.ServerMuted, user.ServerDeafened);
    }

    private void ToggleServerDeafenSelected()
    {
        var user = SelectedUser();
        if (user is null) return;
        _client.SetServerMute(user.Id, user.ServerMuted, !user.ServerDeafened);
    }

    private void SetPermissionsSelected()
    {
        var user = SelectedUser();
        if (user is null) return;
        var current = new PermissionsInfo(false, false, false, false, false, false);
        using var dlg = new PermissionsDialog(user.Nickname, current);
        if (dlg.ShowDialog(this) != DialogResult.OK || dlg.Result is null) return;
        _client.SetPermission(user.Id, dlg.Result);
    }

    // ── Per-user tuning ───────────────────────────────────────────────────────

    private void OpenUserTuning()
    {
        if (lstUsers.SelectedItem is not UserListItem item) return;
        if (!_users.TryGetValue(item.UserId, out var user)) return;
        using var dlg = new PerUserTuningDialog(_client, item.UserId, user.Nickname);
        dlg.ShowDialog(this);
    }

    // ── Text chat ─────────────────────────────────────────────────────────────

    private void TxtCompose_KeyDown(object? sender, KeyEventArgs e)
    {
        if (e.KeyCode == Keys.Enter)
        {
            SendText();
            e.Handled = e.SuppressKeyPress = true;
        }
    }

    private void SendText()
    {
        string msg = txtCompose.Text.Trim();
        if (string.IsNullOrEmpty(msg)) return;
        if (_currentChannelId == 0) return;
        _client.SendText(VcTextScope.Channel, _currentChannelId, msg);
        txtCompose.Clear();
    }

    // ── Log helpers ───────────────────────────────────────────────────────────

    private void AppendChat(string time, string sender, string text)
    {
        rtbLog.AppendText($"[{time}] {sender}: {text}\n");
        rtbLog.ScrollToCaret();
    }

    private void AddActivity(string text)
    {
        string entry = $"[{DateTime.Now:HH:mm}] {text}\n";
        int selStart = rtbLog.TextLength;
        rtbLog.AppendText(entry);
        rtbLog.Select(selStart, entry.Length);
        rtbLog.SelectionColor = Color.Gray;
        rtbLog.Select(rtbLog.TextLength, 0);
        rtbLog.SelectionColor = rtbLog.ForeColor;
        rtbLog.ScrollToCaret();
    }

    private string GetNickname(uint userId)
    {
        if (userId == _selfUserId) return _nickname;
        return _users.TryGetValue(userId, out var u) ? u.Nickname : $"User#{userId}";
    }

    // ── Lifetime ──────────────────────────────────────────────────────────────

    protected override void OnFormClosed(FormClosedEventArgs e)
    {
        _pumpTimer.Stop();
        _client.LevelChanged -= OnLevelChanged;
        _client.EventReceived -= OnEvent;
        foreach (var win in _pmWindows.Values.ToList()) win.Close();
        _pmWindows.Clear();
        if (_screenStreamId != 0) { _screenMixer?.Stop(); _screenMixer?.Dispose(); _screenMixer = null; _client.StopStream(_screenStreamId); }
        if (_auxStreamId != 0) StopAuxStream();
        if (_micStreamId != 0) _client.StopStream(_micStreamId);
        _client.Disconnect();
        _client.Dispose();
        _feedback.Dispose();
        base.OnFormClosed(e);
    }

    // ── Private types ─────────────────────────────────────────────────────────

    private sealed class UserListItem(uint userId, string display)
    {
        public uint UserId { get; } = userId;
        public override string ToString() => display;
    }
}
