use strict;
use warnings;
use utf8;

use Irssi 20100403;
use Irssi::TextUI;
use Irssi::Irc;
use POSIX qw(strftime);
use Time::HiRes qw(time);
use IO::Handle;
use Fcntl qw(O_RDONLY O_WRONLY O_NONBLOCK O_CREAT O_EXCL);
use Encode qw(decode FB_CROAK);
use Scalar::Util qw(refaddr);

# O_NOFOLLOW is optional on non-Unix systems. On supported Unix-like systems it
# closes the symlink race when the optional nicklist FIFO is opened.
my $MATRIX_O_NOFOLLOW = eval { Fcntl::O_NOFOLLOW() } || 0;

our $VERSION = '5.1.56';
# Data-only reload handoff lives in Irssi's persistent namespace, not the script
# package removed on unload. No handlers, timers or native wrappers survive.
# A single snapshot is consumed on the next load; AWAY state expires after five
# minutes. Stale pending WHO leaves discovery paused until reconnect.
# Nothing is serialized to config or disk; process exit discards the handoff.
# IRC identity folding is ASCII plus the server's advertised CASEMAPPING.
# Do not use Unicode lc(): RFC1459 also equates []\\^ with {}|~.
sub matrix_irc_casefold {
    my ($server, $text) = @_;
    $text = '' unless defined $text;
    my $mapping = $server ? (eval { $server->isupport('CASEMAPPING') } // '') : '';
    $mapping = lc($mapping || 'rfc1459');
    $text =~ tr/A-Z/a-z/;
    if ($mapping ne 'ascii') {
        $text =~ tr/[]\\/{}|/;
        $text =~ tr/^/~/ unless $mapping eq 'strict-rfc1459';
    }
    return $text;
}

sub matrix_tag_casefold {
    my ($tag, $text) = @_;
    my $server = defined($tag) && length($tag) ? Irssi::server_find_tag($tag) : undef;
    return matrix_irc_casefold($server, $text);
}

sub matrix_connection_identity {
    my ($server) = @_;
    return '' unless $server && $server->{_irssi};
    return join(':', $server->{_irssi}, $server->{connect_time} // 0,
        $server->{real_connect_time} // 0);
}
our %IRSSI = (
    authors     => 'mxl',
    contact     => 'oskar@ipv4.pl',
    name        => 'matrix',
    description => 'Integrated sidebar-only IRC interface with automatic network/channel navigation, hardened mouse/keyboard controls, Matrix transitions, MAP statistics and an embedded SCREEN/FIFO nicklist',
    license     => 'GPLv2 (embedded nicklist component by Wouter Coekaerts)',
    commands    => 'matrixhelp matrixformats matrixundo matrixfind nickalign gaway autoaway mute unmute matrixnetnext matrixnavup matrixnavdown matrixnavleft matrixnavright matrixmouse matrix pmclear pmunread pmtest prvguard netbars clientsreset clientsmap nicklist',
);

# Preserve native settings and usable pre-load status bars. A saved MATRIX
# layout or an earlier empty bar must fall back to native Irssi defaults when
# the script unloads, rather than restoring callbacks which no longer exist.
my %matrix_saved_key_bindings;
my %matrix_saved_event_formats;
my %matrix_saved_statusbars;
my %matrix_runtime_statusbars_at_load;
my $matrix_saved_event_formats_complete = 0;
my $matrix_saved_statusbars_from_matrix = 0;
my $matrix_public_state_snapshotted = 0;
my $matrix_mouse_raw_bindings_installed = 0;
my $matrix_unloading = 0;
my $matrix_unload_started = 0;
my $matrix_legacy_statusbars_cleaned = 0;
my %matrix_temp_snapshot_dirs;

# Visual state is session-local: never persist message metadata in config.
my %matrix_pm_last_incoming;
my %matrix_pm_recent;
my $matrix_pm_sequence = 0;
my %matrix_pm_page_offset;
my $MATRIX_PM_PAGE_SIZE = 10;
my %matrix_visual_pending;
my %matrix_visual_waiting_query;
my $matrix_visual_age_signature = '';
my $matrix_visual_history_signature = '';
my %matrix_mentions;
my $matrix_mention_jump = 0;

sub matrix_network_colour {
    my ($name) = @_;
    $name = lc($name // '');
    return '%B' if $name eq 'ircnet';
    return '%C' if $name eq 'ircnet2';
    my @palette = ('%B', '%C', '%G', '%Y', '%M');
    my $hash = 0;
    $hash = ($hash * 33 + ord($_)) % 65521 for split //, $name;
    return $palette[$hash % @palette];
}

sub matrix_pm_age_label {
    my ($stamp) = @_;
    return '' unless defined($stamp) && $stamp > 0;
    my $seconds = int(time() - $stamp);
    return 'now' if $seconds < 60;
    return int($seconds / 60) . 'm' if $seconds < 3600;
    return int($seconds / 3600) . 'h' if $seconds < 86400;
    return int($seconds / 86400) . 'd';
}

sub matrix_window_scrolled {
    my ($window) = @_;
    return 0 unless $window;
    my $view = eval { $window->view() };
    return $view && defined($view->{bottom}) && !$view->{bottom} ? 1 : 0;
}

sub matrix_visual_window_key {
    my ($window) = @_;
    return $window ? ($window->{_irssi} // refaddr($window) // '') : '';
}

sub matrix_history_label {
    my $window = Irssi::active_win();
    return '' unless matrix_window_scrolled($window);
    return '' if netbar_sidebar_is_window($window);
    my $count = $matrix_visual_pending{matrix_visual_window_key($window)} // 0;
    return 'History · ↓ Live' . ($count ? ' · New: ' . $count : '');
}

sub matrix_mentions_total {
    my $count = 0;
    $count += $_->{count} for values %matrix_mentions;
    return $count;
}

sub matrix_blue_alert_parts {
    my @parts;
    my $pm = matrix_pm_unread_total();
    my $mentions = matrix_mentions_total();
    push @parts, 'PM: ' . $pm if $pm;
    push @parts, 'Mentions: ' . $mentions if $mentions;
    my $history = matrix_history_label();
    push @parts, $history if length $history;
    return @parts;
}

sub matrix_alerts_redraw {
    return if $matrix_unloading;
    # Include the local calendar day while mentions are pending. The 1-second
    # MATRIX tick then forces exactly one redraw after midnight, so yesterday's
    # mention immediately gains its dd/mm date even if no new IRC event arrives.
    my $mention_day = %matrix_mentions
        ? strftime('%Y-%m-%d', localtime(time())) : '';
    my $signature = join('|', $mention_day, matrix_blue_alert_parts(), map {
        $_ . ':' . $matrix_mentions{$_}->{nick}
    } sort keys %matrix_mentions);
    return if $signature eq $matrix_visual_history_signature;
    $matrix_visual_history_signature = $signature;
    Irssi::statusbar_items_redraw('matrix_sidebar_blue_separator');
}

sub matrix_blue_bar_text {
    my ($width) = @_;
    my @parts = matrix_blue_alert_parts();
    my $context = matrix_sidebar_context_label();
    # Sender and network/channel identify the latest unread mention whenever
    # they fit. Counts and the return-to-live hint take priority over details.
    if (%matrix_mentions) {
        my ($latest) = sort { $b->{last} <=> $a->{last} } values %matrix_mentions;
        my $stamp = $latest->{last} // 0;
        my $last_time = '--:--';
        if ($stamp > 0) {
            my $today = strftime('%Y-%m-%d', localtime(time()));
            my $mention_day = strftime('%Y-%m-%d', localtime($stamp));
            $last_time = $mention_day eq $today
                ? strftime('%H:%M', localtime($stamp))
                : strftime('%d/%m %H:%M', localtime($stamp));
        }
        my $detail = 'Last: ' . $last_time . ' ' . $latest->{nick}
            . ' @ ' . $latest->{tag} . '/' . $latest->{name};
        my $candidate = ' ' . join(' | ', $context, @parts, $detail);
        push @parts, $detail if matrix_text_width($candidate) <= $width;
    }
    my $suffix = @parts ? ' | ' . join(' | ', @parts) : '';
    my $room = $width - 1 - matrix_text_width($suffix);
    if ($room >= 3) {
        return ' ' . matrix_text_truncate_cells($context, $room) . $suffix;
    }
    # Narrow panes retain useful counts rather than clipping them after a long
    # channel name. Never write outside the native statusbar item.
    return matrix_text_truncate_cells(' ' . join(' | ', @parts), $width);
}

sub matrix_mentions_clear {
    my ($key) = @_;
    return unless defined($key) && delete $matrix_mentions{$key};
    matrix_alerts_redraw();
}

sub matrix_mentions_record {
    my ($channel, $window, $nick) = @_;
    my $server = $channel->{server};
    my $key = netbar_channel_key($server, $channel->{name});
    return unless length $key;
    my $state = $matrix_mentions{$key};
    unless ($state) {
        my $bookmark = 'matrix_mention_' . unpack('H*', $key);
        $window->print('%M── First unread mention ──', Irssi::MSGLEVEL_NEVER());
        my $view = eval { $window->view() };
        eval { $view->set_bookmark_bottom($bookmark) } if $view;
        $state = $matrix_mentions{$key} = {
            count => 0, bookmark => $bookmark,
            window_key => matrix_visual_window_key($window),
            tag => netbar_server_tag($server), name => $channel->{name},
        };
    }
    $state->{count}++;
    $state->{nick} = matrix_terminal_plain_text($nick);
    $state->{last} = time();
    matrix_alerts_redraw();
}

sub matrix_mentions_focus {
    return if $matrix_unloading || $matrix_mention_jump;
    my $window = Irssi::active_win();
    return unless $window && !netbar_sidebar_is_window($window);
    my $item = $window->{active};
    return unless $item && $item->{server} && netbar_item_kind($item) eq 'CHANNEL';
    my $key = netbar_channel_key($item->{server}, $item->{name});
    my $state = $matrix_mentions{$key};
    return unless $state && $state->{count};
    my $view = eval { $window->view() };
    return unless $view;
    # Native bookmarks disappear when scrollback is pruned. Never retain or
    # dereference a Line pointer from an earlier message callback.
    my $line = eval { $view->get_bookmark($state->{bookmark}) };
    if ($line) {
        $matrix_mention_jump = 1;
        eval { $view->scroll_line($line); $view->redraw(); };
        $matrix_mention_jump = 0;
    }
    elsif (!$state->{missing_notice}) {
        $state->{missing_notice} = 1;
        $window->print('The first unread mention is no longer in this scrollback.',
            Irssi::MSGLEVEL_CLIENTNOTICE());
    }
    matrix_alerts_redraw();
}

# Runs before Irssi prints the incoming line, so the boundary is in the right
# place in scrollback. No scroll commands or extra windows are used.
sub matrix_visual_incoming {
    my ($server, $message, $nick, $address, $target, $is_action) = @_;
    return unless $server && defined($nick) && length($nick);
    return if pmu_is_own_nick($server, $nick);
    my $item = netbar_find_message_channel($server, $target);
    my $is_channel = $item || eval { $server->ischannel($target // '') } ? 1 : 0;
    my $level = $is_channel ? Irssi::MSGLEVEL_PUBLIC() : Irssi::MSGLEVEL_MSGS();
    $level |= Irssi::MSGLEVEL_ACTIONS() if $is_action;
    # The Perl binding expects a defined channel string, including for PMs.
    return if $server->ignore_check($nick, $address // '',
        $is_channel ? $target : '', $message, $level);
    if ($item) {
        return if netbar_channel_is_muted($server, $item->{name});
    }
    else {
        return if eval { $server->ischannel($target // '') };
        $item = eval { $server->query_find($nick) };
        unless ($item) {
            # Native query creation happens later in this message signal.
            # Finish at query-created, after its window exists but before the
            # first PM is printed. Never create/focus a query ourselves.
            $matrix_visual_waiting_query{pmu_unread_key($server, $nick)} =
                [$server, $message, $nick, $address, $target, $is_action];
            return;
        }
    }
    return unless $item;
    my $window = eval { $item->window() };
    return unless $window;
    my $active = Irssi::active_win();
    my $key = matrix_visual_window_key($window);
    my $live = $active && matrix_visual_window_key($active) eq $key
        && eval { $item->is_active() } && !matrix_window_scrolled($window);
    if ($live) {
        delete $matrix_visual_pending{$key};
        return;
    }
    unless ($matrix_visual_pending{$key}) {
        $window->print('%K── New messages ──', Irssi::MSGLEVEL_NEVER());
    }
    matrix_mentions_record($item, $window, $nick)
        if $is_channel && netbar_message_mentions_own_nick($server, $message);
    $matrix_visual_pending{$key} = ($matrix_visual_pending{$key} // 0) + 1;
}

sub matrix_visual_incoming_action { matrix_visual_incoming(@_, 1); }

sub matrix_visual_query_created {
    my ($query) = @_;
    return unless $query && $query->{server};
    my $key = pmu_unread_key($query->{server}, $query->{name});
    my $pending = delete $matrix_visual_waiting_query{$key};
    matrix_visual_incoming(@$pending) if $pending;
}

sub matrix_visual_private_finished {
    my ($server, $message, $nick) = @_;
    delete $matrix_visual_waiting_query{pmu_unread_key($server, $nick)}
        if $server && defined($nick);
}

sub matrix_visual_own_private_action {
    my ($server, $message, $target) = @_;
    return unless $server && defined($target);
    for my $nick (split /,/, $target) {
        next if $server->ischannel($nick);
        pmu_clear_query_name($server, $nick);
    }
}

sub matrix_visual_private_action {
    my ($server, $message, $nick, $address, $target) = @_;
    return unless $server;
    return if eval { $server->ischannel($target // '') };
    return if $server->ignore_check($nick, $address // '', '', $message,
        Irssi::MSGLEVEL_MSGS() | Irssi::MSGLEVEL_ACTIONS());
    pmu_signal_private_message(@_);
    matrix_visual_private_finished(@_);
}

sub matrix_visual_forget_window {
    my ($window) = @_;
    delete $matrix_visual_pending{matrix_visual_window_key($window)} if $window;
    for my $key (keys %matrix_mentions) {
        matrix_mentions_clear($key)
            if $window && $matrix_mentions{$key}->{window_key} eq matrix_visual_window_key($window);
    }
}

sub matrix_visual_sync {
    return if $matrix_unloading || $matrix_mention_jump;
    my $window = Irssi::active_win();
    if ($window && !netbar_sidebar_is_window($window)
            && !matrix_window_scrolled($window)) {
        my $key = matrix_visual_window_key($window);
        if (delete $matrix_visual_pending{$key}) {
            netbar_clear_current_channel_activity();
            pmu_acknowledge_active_query($window, undef);
            netbar_schedule_refresh();
        }
    }
    matrix_alerts_redraw();
    my $ages = join('|', map {
        $_ . ':' . matrix_pm_age_label($matrix_pm_last_incoming{$_})
    } sort keys %matrix_pm_last_incoming);
    if ($ages ne $matrix_visual_age_signature) {
        $matrix_visual_age_signature = $ages;
        pmu_redraw_statusbar();
    }
}

sub matrix_visual_server_disconnected {
    my ($server) = @_;
    my $prefix = lc(pmu_server_tag($server)) . "\0";
    delete $matrix_pm_last_incoming{$_}
        for grep { index($_, $prefix) == 0 } keys %matrix_pm_last_incoming;
    delete $matrix_pm_recent{$_}
        for grep { index($_, $prefix) == 0 } keys %matrix_pm_recent;
    delete $matrix_pm_page_offset{lc(netbar_network_name_for_server($server))};
    matrix_mentions_clear($_)
        for grep { lc($matrix_mentions{$_}->{tag}) eq lc(pmu_server_tag($server)) } keys %matrix_mentions;
}

sub matrix_visual_signals {
    return (
        ['message public', \&matrix_visual_incoming, 1],
        ['message private', \&matrix_visual_incoming, 1],
        ['message irc action', \&matrix_visual_incoming_action, 1],
        ['message irc action', \&matrix_visual_private_action, 0],
        ['message private', \&matrix_visual_private_finished, 0],
        ['query created', \&matrix_visual_query_created, 0],
        ['message irc own_action', \&matrix_visual_own_private_action, 0],
        ['gui page scrolled', \&matrix_visual_sync, 0],
        ['window changed', \&matrix_mentions_focus, 1],
        ['window changed automatic', \&matrix_mentions_focus, 1],
        ['window item changed', \&matrix_mentions_focus, 1],
        ['window changed', \&matrix_visual_sync, 0],
        ['window item changed', \&matrix_visual_sync, 0],
        ['window destroyed', \&matrix_visual_forget_window, 0],
        ['server disconnected', \&matrix_visual_server_disconnected, 0],
    );
}

sub matrix_config_unescape {
    my ($value) = @_;
    return '' unless defined $value;
    # Decode once: a literal backslash followed by n must not become a newline.
    my %escapes = (n => "\n", r => "\r", t => "\t", '"' => '"', '\\' => '\\');
    $value =~ s/\\([nrt"\\])/$escapes{$1}/ge;
    return $value;
}

sub matrix_read_persisted_config {
    my $dir = eval { Irssi::get_irssi_dir() } || '';
    return '' unless length $dir;
    my $path = $dir . '/config';
    return '' unless -f $path && -r $path;

    open my $fh, '<', $path or return '';
    local $/;
    my $text = <$fh>;
    close $fh;
    return defined($text) ? $text : '';
}

sub matrix_temp_snapshot_path {
    my ($label, $suffix) = @_;
    $label = 'snapshot' unless defined($label) && $label =~ /\A[A-Za-z0-9_-]+\z/;
    $suffix = '' unless defined $suffix;

    my $dir = eval { Irssi::get_irssi_dir() } || '';
    return '' unless length($dir) && -d $dir && -w $dir;

    # Create a private directory atomically, then let Irssi create the snapshot
    # inside it. The previous check-then-save path could be replaced with a
    # symlink between the existence check and /SAVE on a shared directory.
    for (1 .. 8) {
        my $snapshot_dir = $dir . '/.matrix-' . $label . '-' . $$ . '-'
            . int(rand(1_000_000_000));
        next unless mkdir($snapshot_dir, 0700);
        my $path = $snapshot_dir . '/runtime' . $suffix;
        $matrix_temp_snapshot_dirs{$path} = $snapshot_dir;
        return $path;
    }
    return '';
}

sub matrix_cleanup_temp_snapshot {
    my ($path) = @_;
    return unless defined($path) && length($path);

    my $snapshot_dir = delete $matrix_temp_snapshot_dirs{$path};
    unlink $path if -f $path && !-l $path;
    rmdir $snapshot_dir
        if defined($snapshot_dir) && length($snapshot_dir) && -d $snapshot_dir;
}

my $netbar_sidebar_theme_name = '';
my $netbar_sidebar_theme_path = '';
my $netbar_sidebar_theme_device = -1;
my $netbar_sidebar_theme_inode = -1;
my $netbar_sidebar_theme_owned = 0;
my $netbar_sidebar_theme_ready = 0;
sub netbar_sidebar_prepare_black_theme;

sub matrix_read_runtime_config_snapshot {
    my $path = matrix_temp_snapshot_path('config', '.conf');
    return '' unless length $path;

    # Reserve our cached theme name before /SAVE rewrites cached themes. This
    # keeps the file owned and reusable instead of resurrecting an untracked
    # empty file after the previous unload. No theme is selected here.
    netbar_sidebar_prepare_black_theme();
    my $ok = eval {
        Irssi::command('^save ' . matrix_irssi_quote_arg($path));
        1;
    };
    if (!$ok || !-f $path || -l $path || !-r $path) {
        matrix_cleanup_temp_snapshot($path);
        return '';
    }

    open my $fh, '<', $path or do {
        matrix_cleanup_temp_snapshot($path);
        return '';
    };
    local $/;
    my $text = <$fh>;
    close $fh;
    matrix_cleanup_temp_snapshot($path);
    return defined($text) ? $text : '';
}

sub matrix_extract_braced_block_at {
    my ($text, $open) = @_;
    return undef unless defined($text) && defined($open);
    return undef if $open < 0 || $open >= length($text);
    return undef unless substr($text, $open, 1) eq '{';

    my $depth = 0;
    my $quoted = 0;
    my $escaped = 0;
    my $comment = 0;
    for (my $i = $open; $i < length($text); $i++) {
        my $ch = substr($text, $i, 1);
        if ($comment) {
            $comment = 0 if $ch eq "\n";
            next;
        }
        if ($quoted) {
            if ($escaped) {
                $escaped = 0;
                next;
            }
            if ($ch eq '\\') {
                $escaped = 1;
                next;
            }
            if ($ch eq '"') {
                $quoted = 0;
            }
            next;
        }
        if ($ch eq '#') {
            $comment = 1;
            next;
        }
        if ($ch eq '"') {
            $quoted = 1;
            next;
        }
        if ($ch eq '{') {
            $depth++;
            next;
        }
        if ($ch eq '}') {
            $depth--;
            if ($depth == 0) {
                return substr($text, $open + 1, $i - $open - 1);
            }
        }
    }
    return undef;
}

sub matrix_find_named_config_block {
    my ($text, $name) = @_;
    return undef unless defined($text) && defined($name) && length($name);
    if ($text =~ /(?:^|[;\s])(?:\"\Q$name\E\"|\Q$name\E)\s*=\s*\{/mg) {
        my $open = pos($text) - 1;
        return matrix_extract_braced_block_at($text, $open);
    }
    return undef;
}

sub matrix_config_scalar {
    my ($block, $name) = @_;
    return undef unless defined($block) && defined($name);
    if ($block =~ /\b\Q$name\E\s*=\s*"((?:\\.|[^"\\])*)"\s*;/s) {
        return matrix_config_unescape($1);
    }
    if ($block =~ /\b\Q$name\E\s*=\s*([^;\s{}]+)\s*;/s) {
        return $1;
    }
    return undef;
}

# Return direct NAME = { ... } children in their original order. Advancing past
# every complete child block prevents nested item properties from being mistaken
# for siblings.
sub matrix_config_direct_named_blocks {
    my ($block) = @_;
    return () unless defined($block) && length($block);

    my @children;
    pos($block) = 0;
    while ($block =~ /(?:\A|[;\s])(?:"((?:\\.|[^"\\])*)"|([A-Za-z0-9_.:+\/-]+))\s*=\s*\{/mg) {
        my $name = defined($1) ? matrix_config_unescape($1) : $2;
        my $open = pos($block) - 1;
        my $inner = matrix_extract_braced_block_at($block, $open);
        last unless defined $inner;
        push @children, [ $name, $inner ];
        pos($block) = $open + length($inner) + 2;
    }
    return @children;
}

sub matrix_snapshot_statusbars_from_text {
    my ($text) = @_;
    return 0 unless defined($text) && length($text);

    my $statusbar = matrix_find_named_config_block($text, 'statusbar');
    return 0 unless defined $statusbar;
    my $default = matrix_find_named_config_block($statusbar, 'default');
    return 0 unless defined $default;

    my $found = 0;
    for my $bar (qw(window window_inact prompt topic)) {
        my $bar_block = matrix_find_named_config_block($default, $bar);
        next unless defined $bar_block;

        my %saved;
        for my $property (qw(disabled type placement position visible)) {
            my $value = matrix_config_scalar($bar_block, $property);
            $saved{$property} = $value if defined $value;
        }

        my $items_block = matrix_find_named_config_block($bar_block, 'items');
        my @items;
        if (defined $items_block) {
            for my $child (matrix_config_direct_named_blocks($items_block)) {
                my ($name, $item_block) = @$child;
                next unless defined($name)
                    && $name =~ /\A[A-Za-z0-9_.:+\/-]+\z/;
                my $priority = matrix_config_scalar($item_block, 'priority');
                $priority = 0 unless defined($priority) && $priority =~ /\A-?\d+\z/;
                my $alignment = lc(matrix_config_scalar($item_block, 'alignment') // 'left');
                $alignment = 'left' unless $alignment eq 'left' || $alignment eq 'right';
                push @items, {
                    name      => $name,
                    priority  => int($priority),
                    alignment => $alignment,
                };
            }
        }
        $saved{items} = \@items;
        $matrix_saved_statusbars{$bar} = \%saved;
        # A partial persisted config is not an exact runtime snapshot. Never
        # reconstruct a native bar from an invented empty item list.
        $found++ if defined $items_block;
    }

    # /SAVE while MATRIX is running persists its disabled native bars and
    # script-only prompt. After a restart or upgrade this is not a native
    # layout to restore: its callbacks disappear during UNLOAD.
    my $matrix_items = grep {
        grep { matrix_statusbar_item_is_owned($_->{name}) } @{$_->{items}}
    } values %matrix_saved_statusbars;
    my $empty_window = grep {
        my $saved = $matrix_saved_statusbars{$_};
        $saved && !@{$saved->{items}}
    } qw(window window_inact);
    my $matrix_bars = grep {
        $_->[0] =~ /\A(?:matrix_|network_channels_\d{2}\z)/i
    } matrix_config_direct_named_blocks($default);
    $matrix_saved_statusbars_from_matrix = $matrix_items
        || ($empty_window && $matrix_bars) ? 1 : 0;
    return $found;
}

sub matrix_snapshot_statusbar_names_from_text {
    my ($text) = @_;
    return 0 unless defined($text) && length($text);

    my $statusbar = matrix_find_named_config_block($text, 'statusbar');
    return 0 unless defined $statusbar;
    my $default = matrix_find_named_config_block($statusbar, 'default');
    return 0 unless defined $default;

    my $found = 0;
    for my $child (matrix_config_direct_named_blocks($default)) {
        my ($name) = @$child;
        next unless defined($name) && length($name);
        $matrix_runtime_statusbars_at_load{lc($name)} = $name;
        $found++;
    }
    return $found;
}

sub matrix_snapshot_key_bindings_from_config {
    my ($text) = @_;
    return unless defined($text) && length($text);
    my %wanted = map { $_ => 1 } (
        '^X',
        'mleft', 'mright', 'mup', 'mdown',
        # GNU screen/SSH and some xterm-compatible terminals expose modified
        # arrows as their literal CSI sequence instead of Irssi's symbolic key.
        'meta-[1;3D', 'meta-[1;3C', 'meta-[1;3A', 'meta-[1;3B',
        'meta-[M', 'meta-[<',
    );

    while ($text =~ /\{([^{}]{0,4096})\}/sg) {
        my $block = $1;
        my $key = matrix_config_scalar($block, 'key');
        my $id  = matrix_config_scalar($block, 'id');
        next unless defined($key) && $wanted{$key};
        next unless defined($id) && $id =~ /\A[A-Za-z0-9_.:+\/-]+\z/;
        my $data = matrix_config_scalar($block, 'data');
        $matrix_saved_key_bindings{$key} = {
            id   => $id,
            data => defined($data) ? $data : '',
        };
    }
}

sub matrix_snapshot_event_formats_runtime;
sub matrix_snapshot_event_formats_from_text;

sub matrix_snapshot_public_runtime_state {
    return if $matrix_public_state_snapshotted;
    $matrix_public_state_snapshotted = 1;

    my $config = matrix_read_runtime_config_snapshot();
    $config = matrix_read_persisted_config() unless length $config;
    if (length $config) {
        matrix_snapshot_key_bindings_from_config($config);
        matrix_snapshot_statusbars_from_text($config);
        matrix_snapshot_statusbar_names_from_text($config);
    }

    # Capture current runtime formats, including unsaved /FORMAT changes,
    # before MATRIX applies overrides. If the theme API cannot supply all
    # formats, fall back to persisted overrides from the normal config.
    matrix_snapshot_event_formats_runtime();
    matrix_snapshot_event_formats_from_text($config)
        if !$matrix_saved_event_formats_complete && length($config);
}

sub matrix_irssi_quote_arg {
    my ($value) = @_;
    $value = '' unless defined $value;
    $value =~ s/\\/\\\\/g;
    $value =~ s/"/\\"/g;
    $value =~ s/[\x00-\x1f\x7f]//g;
    return '"' . $value . '"';
}

sub matrix_decode_valid_utf8 {
    my ($text) = @_;
    $text = '' unless defined $text;
    if (!utf8::is_utf8($text) && length($text)) {
        my $decoded = eval { decode('UTF-8', $text, FB_CROAK) };
        $text = $decoded if defined($decoded) && !$@;
    }
    return $text;
}

sub matrix_terminal_plain_text {
    my ($text) = @_;
    $text = matrix_decode_valid_utf8($text);
    $text =~ s/[\x00-\x1f\x7f-\x9f]//g;
    return $text;
}

# Irssi 1.2+ exposes its own wcwidth implementation to Perl. Use it when
# available so wide UTF-8 glyphs and combining characters do not shift padding
# or mouse hitboxes; retain a safe fallback for older supported builds.
sub matrix_text_width {
    my ($text) = @_;
    $text = '' unless defined $text;
    if (Irssi->can('string_width')) {
        my $width = eval { Irssi::string_width($text) };
        return int($width) if defined($width) && !$@ && $width =~ /\A\d+\z/;
    }
    return length($text);
}

sub matrix_text_truncate_cells {
    my ($text, $cells) = @_;
    $text = '' unless defined $text;
    $cells = int($cells // 0);
    return '' if $cells <= 0;
    return $text if matrix_text_width($text) <= $cells;

    if (Irssi->can('string_chars_for_width')) {
        my $characters = eval { Irssi::string_chars_for_width($text, $cells) };
        if (defined($characters) && !$@ && $characters =~ /\A\d+\z/) {
            return substr($text, 0, int($characters));
        }
    }
    return substr($text, 0, $cells);
}

sub matrix_saved_binding_is_our_navigation_command {
    my ($saved) = @_;
    return 0 unless $saved && defined($saved->{id}) && defined($saved->{data});
    return 0 unless lc($saved->{id}) eq 'command';
    my $data = uc($saved->{data});
    $data =~ s/^\s+|\s+$//g;
    return $data =~ /\A(?:MATRIXNETNEXT|MATRIXNAVLEFT|MATRIXNAVRIGHT|MATRIXNAVUP|MATRIXNAVDOWN)\z/
        ? 1 : 0;
}

sub matrix_restore_one_saved_binding {
    my ($key, $saved) = @_;

    # A stale binding left by an older MATRIX must not be restored after its
    # command handlers disappear. With no genuine snapshot, use Irssi's own
    # default for the normal navigation key.
    if (!$saved || matrix_saved_binding_is_our_navigation_command($saved)) {
        Irssi::command('^bind -reset ' . $key);
        return;
    }

    my $id = $saved->{id} // '';
    return Irssi::command('^bind -reset ' . $key)
        unless $id =~ /\A[A-Za-z0-9_.:+\/-]+\z/;

    my $command = '^bind -delete ' . $key;
    Irssi::command($command);
    $command = '^bind ' . $key . ' ' . $id;
    my $data = $saved->{data} // '';
    $command .= ' ' . matrix_irssi_quote_arg($data) if length $data;
    Irssi::command($command);
}

sub matrix_restore_saved_key_bindings {
    for my $key (
        '^X',
        'mleft', 'mright', 'mup', 'mdown',
        'meta-[1;3D', 'meta-[1;3C', 'meta-[1;3A', 'meta-[1;3B',
    ) {
        matrix_restore_one_saved_binding($key, $matrix_saved_key_bindings{$key});
    }

    # Remove raw terminal bindings only when this exact MATRIX instance installed
    # them. If an external mouse binding caused takeover to be refused, leave
    # both external keys completely untouched.
    if ($matrix_mouse_raw_bindings_installed) {
        for my $key ('meta-[M', 'meta-[<') {
            Irssi::command('^bind -delete ' . $key);
        }
        $matrix_mouse_raw_bindings_installed = 0;
    }
}

sub matrix_statusbar_item_is_owned {
    my ($name) = @_;
    return defined($name) && ($name =~ /\Amatrix_/i
        || $name eq 'pm_unread' || $name eq 'prv_status') ? 1 : 0;
}

sub matrix_saved_statusbar_is_usable {
    my ($bar, $saved) = @_;
    return 0 unless $saved && ref($saved->{items}) eq 'ARRAY';
    return 0 if grep { matrix_statusbar_item_is_owned($_->{name}) }
        @{$saved->{items}};
    # An empty bar (or one containing only borders/input) is residue from a
    # broken unload, not a usable native status/prompt. Restore its defaults.
    return scalar grep {
        my $name = $_->{name} // '';
        length($name) && $name !~ /\A(?:barstart|barend|topicbarstart|topicbarend)\z/
            && !($bar eq 'prompt' && $name eq 'input')
    } @{$saved->{items}};
}

sub matrix_restore_saved_statusbars {
    my @native = @_ ? @_ : qw(window window_inact prompt topic);
    if ($matrix_saved_statusbars_from_matrix) {
        # Script-owned saved layouts cannot be used after callbacks disappear.
        Irssi::command('^statusbar reset ' . $_) for @native;
        return;
    }

    # /STATUSBAR RESET alone discards custom pre-MATRIX positions and items.
    # Recreate exactly the captured native bars, while the unload guard keeps
    # setup-change callbacks from rebuilding MATRIX during restoration.
    for my $bar (@native) {
        my $saved = $matrix_saved_statusbars{$bar};
        unless (matrix_saved_statusbar_is_usable($bar, $saved)) {
            Irssi::command('^statusbar reset ' . $bar);
            next;
        }
        Irssi::command('^statusbar reset ' . $bar);
        for my $item (qw(
            barstart barend time user window window_empty lag act more
            topicbarstart topicbarend topic topic_empty prompt prompt_empty input
            matrix_dark_fill matrix_sidebar_blank_row matrix_sidebar_blue_separator
            matrix_sidebar_topic matrix_prompt_context matrix_sidebar_clock
            matrix_sidebar_clock_pm matrix_sidebar_act_overflow pm_unread prv_status
        )) {
            Irssi::command('^statusbar removeitem ' . $item . ' ' . $bar);
        }
        for my $item (@{$saved->{items}}) {
            my $name = $item->{name} // '';
            next unless $name =~ /\A[A-Za-z0-9_.:+\/-]+\z/;
            # Saved /SAVE output may still contain residue from an older MATRIX.
            # Never re-add callbacks which vanish together with this script.
            next if matrix_statusbar_item_is_owned($name);
            my $alignment = $item->{alignment} eq 'right' ? 'right' : 'left';
            my $priority = int($item->{priority} // 0);
            # Irssi's -priority parser requires a quoted negative value.
            my $priority_arg = $priority < 0 ? '"' . $priority . '"' : $priority;
            Irssi::command(
                '^statusbar additem -alignment ' . $alignment
                . ' -priority ' . $priority_arg . ' ' . $name . ' ' . $bar
            );
        }
        my @flags;
        my $type = lc($saved->{type} // '');
        push @flags, '-type ' . $type if $type =~ /\A(?:root|window)\z/;
        my $placement = lc($saved->{placement} // '');
        push @flags, '-placement ' . $placement
            if $placement =~ /\A(?:top|bottom)\z/;
        my $position = $saved->{position} // '';
        push @flags, '-position ' . int($position)
            if $position =~ /\A-?\d+\z/;
        my $visible = lc($saved->{visible} // '');
        push @flags, '-visible ' . $visible
            if $visible =~ /\A(?:active|inactive|always)\z/;
        my $disabled = lc($saved->{disabled} // '');
        push @flags, $disabled =~ /\A(?:1|yes|true|on)\z/
            ? '-disable' : '-nodisable';
        Irssi::command('^statusbar modify ' . join(' ', @flags) . ' ' . $bar);
    }
}

# Old MATRIX/DUAL releases used several additional root/window bars. They are
# persistent Irssi configuration nodes rather than Perl-owned objects, so
# unloading a script does not remove them automatically. If one survives a
# reload, the shared matrix_sidebar_clock_pm item is drawn both there and in
# the native window bar: an incoming PM then exposes two Act rows. ADD
# -disable is deliberately used instead of MODIFY -disable because it is safe
# even when a clean profile has never seen that historical bar.
sub matrix_disable_legacy_statusbars {
    my ($force, $remove) = @_;
    return if $matrix_legacy_statusbars_cleaned && !$force;

    my %bars = map { lc($_) => $_ } qw(
        matrix_light_bar
        matrix_top_gap_bar
        matrix_clock_bar
        matrix_network_gap_bar
        clients_activity_bar
        matrix_network_stats_bar
        matrix_sidebar_act_gap_bar
        matrix_sidebar_info_bar
        matrix_sidebar_act_overflow_bar
        matrix_sidebar_input_gap_bar
        matrix_sidebar_chat_top_gap_bar
        matrix_sidebar_blue_separator_bar
        matrix_sidebar_chat_gap_bar
        matrix_sidebar_stats_bar
    );

    # Compact/Normal DUAL builds could leave up to 64 channel rows behind. Add
    # only the rows present in the runtime snapshot so a clean profile is not
    # filled with 64 unnecessary disabled configuration nodes. If the snapshot
    # could not be parsed, use the complete safe fallback range.
    my @legacy_channel_bars = map {
        $matrix_runtime_statusbars_at_load{$_}
    } grep {
        /^network_channels_\d{2}$/
    } keys %matrix_runtime_statusbars_at_load;
    if (@legacy_channel_bars) {
        $bars{lc($_)} = $_ for @legacy_channel_bars;
    }
    elsif (!keys %matrix_runtime_statusbars_at_load) {
        for my $slot (1 .. 64) {
            my $bar = sprintf('network_channels_%02d', $slot);
            $bars{lc($bar)} = $bar;
        }
    }

    for my $bar (values %bars) {
        if ($remove) {
            Irssi::command('^statusbar reset ' . $bar);
            next;
        }
        # ADD handles never-before-seen names, MODIFY also disables an already
        # existing bar (ADD alone is not an update on every Irssi version).
        Irssi::command('^statusbar add -disable ' . $bar);
        Irssi::command('^statusbar modify -disable ' . $bar);
    }
    $matrix_legacy_statusbars_cleaned = 1;
}

sub matrix_disable_owned_statusbars {
    my ($remove) = @_;
    # Force cleanup here: the current MATRIX release deliberately owns some of
    # the historical bar names while loaded. A normal guarded cleanup would
    # leave them enabled during UNLOAD after the first startup pass.
    matrix_disable_legacy_statusbars(1, $remove);
    for my $bar (qw(
        matrix_sidebar_topic_bar matrix_sidebar_topic_gap_bar
        matrix_sidebar_chat_top_gap_bar matrix_sidebar_blue_separator_bar
    )) {
        if ($remove) {
            Irssi::command('^statusbar reset ' . $bar);
            next;
        }
        Irssi::command('^statusbar add -disable ' . $bar);
        Irssi::command('^statusbar modify -disable ' . $bar);
    }
}

sub matrix_saved_binding_is_our_mouse_command {
    my ($key, $saved) = @_;
    return 0 unless $saved && defined($saved->{id}) && defined($saved->{data});
    return 0 unless lc($saved->{id}) eq 'command';
    my $data = uc($saved->{data});
    return 1 if $key eq 'meta-[M' && $data eq 'MATRIXMOUSEXTERM';
    return 1 if $key eq 'meta-[<' && $data eq 'MATRIXMOUSESGR';
    return 0;
}

sub matrix_saved_external_mouse_binding_present {
    for my $key ('meta-[M', 'meta-[<') {
        my $saved = $matrix_saved_key_bindings{$key};
        next unless $saved;
        return 1 unless matrix_saved_binding_is_our_mouse_command($key, $saved);
    }
    return 0;
}

# IMPORTANT: everything intentionally remains in package main. Irssi 1.4.x
# associates settings, signals, timers and statusbar items with the package of
# the loaded script. Splitting one file into Perl packages causes script=NULL
# assertions and CODE(...) callback failures.

my $matrix_dark_tick_tag;
my $matrix_layout_settle_tag;
my @matrix_startup_settle_tags;
my $matrix_timestamp_format_saved = 0;
my $matrix_previous_timestamp_format = undef;
my $matrix_previous_hilight_nick_matches_everywhere = 0;
my $matrix_hilight_nick_matches_everywhere_saved = 0;

# Force ordinary Irssi lines to use a timestamp with seconds. Brackets are
# supplied by the MATRIX timestamp abstract so they can stay dark gray while
# the time itself remains bright white.
# Track whether MATRIX applied its seconds-based timestamp format.
sub matrix_apply_message_timestamp_format {
    if (!$matrix_timestamp_format_saved) {
        $matrix_previous_timestamp_format =
            Irssi::settings_get_str('timestamp_format');
        $matrix_timestamp_format_saved = 1;
    }
    my $changed = 0;
    if (Irssi::settings_get_str('timestamp_format') ne '%H:%M:%S') {
        Irssi::settings_set_str('timestamp_format', '%H:%M:%S');
        $changed = 1;
    }

    # Irssi normally recognises an own-nick highlight only at the beginning of
    # a public line. MATRIX treats the same standalone nick anywhere in the
    # sentence identically, e.g. both "mxl: hi" and "hi, mxl are you there?".
    if (!$matrix_hilight_nick_matches_everywhere_saved) {
        $matrix_previous_hilight_nick_matches_everywhere =
            Irssi::settings_get_bool('hilight_nick_matches_everywhere') ? 1 : 0;
        $matrix_hilight_nick_matches_everywhere_saved = 1;
    }
    if (!Irssi::settings_get_bool('hilight_nick_matches_everywhere')) {
        Irssi::settings_set_bool('hilight_nick_matches_everywhere', 1);
        $changed = 1;
    }

    # Irssi caches the rendered timestamp format. Avoid dispatching setup
    # changed (and restarting unrelated timers) if nothing changed.
    Irssi::signal_emit('setup changed') if $changed;
}

sub matrix_restore_message_timestamp_format {
    return unless $matrix_timestamp_format_saved;
    # Restore the user's original value, not a hard-coded MATRIX default.
    Irssi::settings_set_str(
        'timestamp_format',
        defined($matrix_previous_timestamp_format)
            ? $matrix_previous_timestamp_format : '%H:%M',
    );
    $matrix_previous_timestamp_format = undef;
    $matrix_timestamp_format_saved = 0;
    if ($matrix_hilight_nick_matches_everywhere_saved) {
        Irssi::settings_set_bool(
            'hilight_nick_matches_everywhere',
            $matrix_previous_hilight_nick_matches_everywhere ? 1 : 0,
        );
        $matrix_hilight_nick_matches_everywhere_saved = 0;
    }
    Irssi::signal_emit('setup changed');
}

# -----------------------------------------------------------------------------
# Monochrome IRC event formats
# -----------------------------------------------------------------------------
# These are regular Irssi /FORMAT overrides rather than duplicate print hooks.
# Core IRC state handling, ignores, window levels and the network-bar callbacks
# therefore keep working normally.  The overrides are reset when the script is
# unloaded.
# Public-message nickname alignment. RIGHT preserves the MATRIX compact 9-cell
# column; LEFT uses the natural Irssi layout without padding. /nickalign toggles
# between them without touching the user's saved theme.
Irssi::settings_add_str('matrix', 'matrix_nick_alignment', 'right');

my %matrix_left_nick_formats = (
    own_msg =>
        '{ownmsgnick $2 {ownnick $0}}$1',
    own_msg_channel =>
        '{ownmsgnick $3 {ownnick $0}{msgchannel $1}}$2',
    pubmsg_me =>
        '{pubmsgmenick $2 {menick $0}}$1',
    pubmsg_me_channel =>
        '{pubmsgmenick $3 {menick $0}{msgchannel $1}}$2',
    pubmsg_hilight =>
        '{pubmsghinick $0 $3 $1}$2',
    pubmsg_hilight_channel =>
        '{pubmsghinick $0 $4 $1{msgchannel $2}}$3',
    pubmsg =>
        '{pubmsgnick $2 {pubnick $0}}$1',
    pubmsg_channel =>
        '{pubmsgnick $3 {pubnick $0}{msgchannel $1}}$2',
);

sub matrix_nick_alignment_mode {
    my $mode = lc(Irssi::settings_get_str('matrix_nick_alignment') // 'right');
    return $mode eq 'left' ? 'left' : 'right';
}

sub matrix_event_format_value {
    my ($module, $key, $right_value) = @_;
    return $right_value unless matrix_nick_alignment_mode() eq 'left';
    return $right_value unless lc($module // '') eq 'core';
    return exists($matrix_left_nick_formats{$key})
        ? $matrix_left_nick_formats{$key}
        : $right_value;
}

my @matrix_event_formats = (
    # IMPORTANT: /FORMAT expects the public module names "core" and "irc".
    # Paths such as fe-common/core are source-tree paths and are not accepted
    # by the Irssi /FORMAT command.

    # Keep only the brackets dark gray and render the time bright white.
    # The leading blank cell preserves the existing public/PM line indent.
    ['core', 'timestamp',
        ' %K[%W$Z%K]%n'],

    # Public-channel conversation nicks are right-aligned inside a compact
    # nine-cell column. The ! modifier keeps nicknames longer than nine cells
    # intact instead of truncating them. Channel modes (@/+/...) remain on the
    # left side of the padded nickname, for example: <+      matrix>.
    ['core', 'own_msg',
        '{ownmsgnick $2 {ownnick $[!-9]0}}$1'],
    ['core', 'own_msg_channel',
        '{ownmsgnick $3 {ownnick $[!-9]0}{msgchannel $1}}$2'],
    ['core', 'pubmsg_me',
        '{pubmsgmenick $2 {menick $[!-9]0}}$1'],
    ['core', 'pubmsg_me_channel',
        '{pubmsgmenick $3 {menick $[!-9]0}{msgchannel $1}}$2'],
    ['core', 'pubmsg_hilight',
        '{pubmsghinick $0 $3 $[!-9]1}$2'],
    ['core', 'pubmsg_hilight_channel',
        '{pubmsghinick $0 $4 $[!-9]1{msgchannel $2}}$3'],
    ['core', 'pubmsg',
        '{pubmsgnick $2 {pubnick $[!-9]0}}$1'],
    ['core', 'pubmsg_channel',
        '{pubmsgnick $3 {pubnick $[!-9]0}{msgchannel $1}}$2'],

    # JOIN: dark gray >, normal gray >, bright-white >.
    ['core', 'join',
        '%K>%w>%W> %W$0 %K[%w$1%K] %whas joined %W$2%N'],
    ['core', 'join_extended',
        '%K>%w>%W> %W$0 %K[%w$1%K] %whas joined %W$2%N'],
    ['core', 'join_extended_account',
        '%K>%w>%W> %W$0 %K[%w$1%K] %whas joined %W$2%N'],

    # PART: the JOIN gradient in reverse.
    ['core', 'part',
        '%W<%w<%K< %w$0 %K[%K$1%K] %whas left %W$2 %K[$3]%N'],

    # QUIT keeps only the <<< marker red. Nick and ident@host use the same
    # normal-gray colour; KICK also keeps only its <<< marker red.
    # The ordinary quit format has no channel argument. quit_once includes
    # the channel in $3 when Irssi prints one quit only once for that window.
    ['core', 'quit',
        '%R<<< %w$0 %K[%w$1%K] %whas quit %K[$2]%N'],
    ['core', 'quit_once',
        '%R<<< %w$0 %K[%w$1%K] %whas quit %W$3 %K[$2]%N'],
    ['core', 'kick',
        '%R<<<%W $0 %wwas kicked from %W$1 %wby %W$2 %K[$3]%N'],

    # Nick changes.
    ['core', 'nick_changed',
        '%K~%w~%W> %W$0 %K-> %W$1%N'],
    ['core', 'your_nick_changed',
        '%K~%w~%W> %wYou are now known as %W$1%N'],

    # Topic and other informational events use one > so they do not
    # visually merge with the three-arrow JOIN marker.
    ['core', 'new_topic',
        '%K>%w   topic on %W$1 %wset by %W$0%K: %w$2%N'],
    ['core', 'topic_unset',
        '%K>%w   topic on %W$1 %wunset by %W$0%N'],
    ['core', 'endofnames',
        '%K>%w   %W$0%K: %w$1 nicks %K[$2 ops, $3 halfops, $4 voices, $5 normal]%N'],

    # Topic, MODE and sync information belongs to the public "irc" module.
    # Each uses one > followed by three spaces; JOIN/PART/QUIT/KICK stay unchanged.
    ['irc', 'topic',
        '%K>%w   Topic for %W$0%K: %w$1%N'],
    ['irc', 'topic_info',
        '%K>%w   Topic set by %W$0 %K[$2] [$1]%N'],
    ['irc', 'no_topic',
        '%K>%w   No topic set for %W$0%N'],
    ['irc', 'channel_synced',
        '%K>%w   %W$0 %wsynced in %K$1 %wseconds%N'],
    ['irc', 'chanmode_change',
        '%K>%w   mode/%W$0 %K[$1] %wby %W$2%N'],
    ['irc', 'server_chanmode_change',
        '%K>%w   server mode/%W$0 %K[$1] %wby %W$2%N'],
    ['irc', 'channel_mode',
        '%K>%w   mode/%W$0 %K[$1]%N'],
);


sub matrix_event_format_aliases {
    my ($module) = @_;
    return ($module, 'fe-common/' . $module);
}

sub matrix_snapshot_event_formats_from_text {
    my ($text) = @_;
    return 0 unless defined($text) && length($text);

    my $formats = matrix_find_named_config_block($text, 'formats');
    return 0 unless defined $formats;

    my $found = 0;
    for my $entry (@matrix_event_formats) {
        my ($module, $key) = @$entry;
        my $value;
        for my $alias (matrix_event_format_aliases($module)) {
            my $module_block = matrix_find_named_config_block($formats, $alias);
            next unless defined $module_block;
            $value = matrix_config_scalar($module_block, $key);
            last if defined $value;
        }
        next unless defined $value;
        # Never feed control characters/newlines from a file back into the Irssi
        # command parser. Ordinary theme format control uses printable % codes.
        next if $value =~ /[\x00-\x1f\x7f]/;
        my $saved_key = lc($module) . "\0" . $key;
        # Runtime theme state has priority because it can contain unsaved
        # /FORMAT changes. Persisted config is only a fallback for missing keys.
        $matrix_saved_event_formats{$saved_key} = $value
            unless exists $matrix_saved_event_formats{$saved_key};
        $found++;
    }

    $matrix_saved_event_formats_complete = !grep {
        my ($module, $key) = @$_;
        !exists $matrix_saved_event_formats{lc($module) . "\0" . $key};
    } @matrix_event_formats;
    return $found;
}

sub matrix_snapshot_event_formats_runtime {
    # Read the active theme directly. /SAVE -formats also writes every cached
    # theme, resurrecting private sidebar files from earlier script loads.
    my $theme = eval { Irssi::current_theme() };
    return 0 unless $theme;

    my $found = 0;
    for my $entry (@matrix_event_formats) {
        my ($module, $key) = @$entry;
        my $value;
        for my $alias (matrix_event_format_aliases($module)) {
            $value = eval { $theme->get_format($alias, $key) };
            last if defined($value) && !$@;
        }
        next unless defined($value) && $value !~ /[\x00-\x1f\x7f]/;
        $matrix_saved_event_formats{lc($module) . "\0" . $key} = $value;
        $found++;
    }
    $matrix_saved_event_formats_complete = 1
        if $found == scalar(@matrix_event_formats);
    return $found;
}

# Capture bindings, event formats and the four native bars before any embedded
# module can remove an older PM item or otherwise alter the user's layout.
matrix_snapshot_public_runtime_state();

my $matrix_event_formats_reapply_tag;

sub matrix_apply_event_formats {
    for my $entry (@matrix_event_formats) {
        my ($module, $key, $right_value) = @$entry;
        my $value = matrix_event_format_value($module, $key, $right_value);
        Irssi::command('^format ' . $module . ' ' . $key . ' ' . $value);
    }
}

sub matrix_restore_event_formats {
    Irssi::timeout_remove($matrix_event_formats_reapply_tag)
        if defined $matrix_event_formats_reapply_tag;
    undef $matrix_event_formats_reapply_tag;

    for my $entry (@matrix_event_formats) {
        my ($module, $key) = @$entry;
        my $saved_key = lc($module) . "\0" . $key;
        if (exists $matrix_saved_event_formats{$saved_key}) {
            my $value = $matrix_saved_event_formats{$saved_key};
            if (defined($value) && $value !~ /[\x00-\x1f\x7f]/) {
                Irssi::command('^format ' . $module . ' ' . $key . ' ' . $value);
                next;
            }
        }
        Irssi::command('^format -reset ' . $module . ' ' . $key);
    }
}

sub matrix_run_event_formats_reapply {
    undef $matrix_event_formats_reapply_tag;
    return if $matrix_unloading;
    # A theme/config reread replaces formats AND persisted statusbar geometry.
    # Reconstruct them in one frozen frame so there is no intermediate prompt
    # without its Act/topic companions. Do not force a WINDOW split rebuild.
    matrix_with_frozen_refresh(sub {
        matrix_apply_message_timestamp_format();
        matrix_apply_event_formats();
        netbar_install_navigation_bindings()
            if defined &netbar_install_navigation_bindings;
        if (defined(&netbar_sidebar_reapply_chrome)
            && defined(&netbar_sidebar_window)
            && netbar_sidebar_window()
            && Irssi::settings_get_bool('network_statusbars_enabled')) {
            netbar_sidebar_reapply_chrome();
        }
        else {
            matrix_apply_dynamic_prompt() if defined &matrix_apply_dynamic_prompt;
            netbar_schedule_refresh() if defined &netbar_schedule_refresh;
        }
    });
}

sub matrix_command_formats {
    matrix_apply_event_formats();
    matrix_apply_dynamic_prompt() if defined &matrix_apply_dynamic_prompt;
    my $mode = matrix_nick_alignment_mode() eq 'left'
        ? 'LEFT (native Irssi)'
        : 'RIGHT (9-character field)';
    Irssi::print('MATRIX: applied timestamp, nickname alignment ' . $mode . ', dynamic PROMPT and JOIN/PART/QUIT/KICK/NICK/TOPIC/MODE/SYNC formats.', Irssi::MSGLEVEL_CLIENTCRAP());
}

sub matrix_command_nickalign {
    my ($data, $server, $witem) = @_;
    $data = '' unless defined $data;
    $data =~ s/^\s+|\s+$//g;
    my $arg = lc($data);

    if ($arg eq 'status') {
        my $mode = matrix_nick_alignment_mode();
        Irssi::print(
            $mode eq 'left'
                ? 'MATRIX nickalign: LEFT - native Irssi alignment, without padding.'
                : 'MATRIX nickalign: RIGHT - nicknames right-aligned in a 9-character field.'
        );
        return;
    }

    my $mode;
    if ($arg eq '' || $arg eq 'toggle') {
        $mode = matrix_nick_alignment_mode() eq 'right' ? 'left' : 'right';
    }
    elsif ($arg eq 'left' || $arg eq 'default') {
        $mode = 'left';
    }
    elsif ($arg eq 'right') {
        $mode = 'right';
    }
    else {
        Irssi::print('Usage: /nickalign [left|right|default|status]  (no argument = toggle)');
        return;
    }

    Irssi::settings_set_str('matrix_nick_alignment', $mode);
    matrix_apply_event_formats();
    Irssi::command('^redraw');
    Irssi::print(
        $mode eq 'left'
            ? 'MATRIX nickalign: LEFT - native Irssi alignment, without padding.'
            : 'MATRIX nickalign: RIGHT - nicknames right-aligned in a 9-character field.'
    );
}

Irssi::command_bind('matrixformats', 'matrix_command_formats');
Irssi::command_bind('nickalign', 'matrix_command_nickalign');

sub matrix_schedule_event_formats_reapply {
    return if $matrix_unloading;
    Irssi::timeout_remove($matrix_event_formats_reapply_tag)
        if defined $matrix_event_formats_reapply_tag;
    $matrix_event_formats_reapply_tag = Irssi::timeout_add_once(
        250,
        'matrix_run_event_formats_reapply',
        0,
    );
}

# /RELOAD or a theme reload may replace custom /FORMAT values. Reapply them
# once the new configuration/theme is fully installed.
Irssi::signal_add_last('setup reread', 'matrix_schedule_event_formats_reapply');
Irssi::signal_add_last('theme created', 'matrix_schedule_event_formats_reapply');


# Minimal shell-style prompt. The adjacent built-in input item keeps Irssi's
# normal editing behavior and the stable MATRIX cursor.
sub matrix_prompt_context {
    my ($item, $get_size_only) = @_;
    return unless $item;
    # Irssi uses $$ for a literal dollar; a single $ starts an expando.
    $item->default_handler($get_size_only, '%0%W ~$$%0%W ', '', 1);
}

# Keep every related Irssi repaint inside one terminal frame. This is the same
# refresh discipline used by the stable DUAL Compact/Normal layouts: curses
# remains the sole owner of the physical cursor while multiple statusbar and
# sidebar changes are committed together.
my $matrix_refresh_freeze_depth = 0;

sub matrix_with_frozen_refresh {
    my ($callback) = @_;
    return 0 unless ref($callback) eq 'CODE';

    my $outer = $matrix_refresh_freeze_depth++;
    Irssi::term_refresh_freeze() unless $outer;

    my $ok = eval {
        $callback->();
        1;
    };
    my $error = $@;

    $matrix_refresh_freeze_depth--;
    Irssi::term_refresh_thaw() unless $outer;
    die $error unless $ok;
    return 1;
}

sub matrix_schedule_cursor_reassert {
    return if $matrix_unloading;
    netbar_matrix_input_cursor_schedule_apply(90)
        if defined &netbar_matrix_input_cursor_schedule_apply;
}

my $matrix_bottom_rows_redraw_tag;
my $matrix_bottom_rows_prompt_pending = 0;

sub matrix_redraw_bottom_rows_now {
    my ($include_prompt) = @_;
    return if $matrix_unloading;
    matrix_with_frozen_refresh(sub {
        Irssi::statusbar_items_redraw('matrix_sidebar_clock');
        Irssi::statusbar_items_redraw('matrix_sidebar_blue_separator');
        Irssi::statusbar_items_redraw('matrix_prompt_context')
            if $include_prompt;
    });

}

sub matrix_run_bottom_rows_redraw {
    if ($matrix_unloading) {
        $matrix_bottom_rows_redraw_tag = undef;
        $matrix_bottom_rows_prompt_pending = 0;
        return;
    }
    my $include_prompt = $matrix_bottom_rows_prompt_pending ? 1 : 0;
    $matrix_bottom_rows_redraw_tag = undef;
    $matrix_bottom_rows_prompt_pending = 0;
    matrix_redraw_bottom_rows_now($include_prompt);
}

sub matrix_schedule_bottom_rows_redraw {
    my ($include_prompt) = @_;
    return if $matrix_unloading;
    $matrix_bottom_rows_prompt_pending = 1 if $include_prompt;
    return if defined $matrix_bottom_rows_redraw_tag;
    $matrix_bottom_rows_redraw_tag = Irssi::timeout_add_once(
        20,
        'matrix_run_bottom_rows_redraw',
        0,
    );
}

my $matrix_prompt_context_signature = '';

sub matrix_active_prompt_signature {
    my $window = Irssi::active_win();
    return '' unless $window;

    my $active = $window->{active};
    my $server = $active && $active->{server}
        ? $active->{server}
        : $window->{active_server};
    return join("\x1f",
        int($window->{refnum} // 0),
        $active ? ($active->{type} // ref($active) // '') : '',
        $active ? ($active->{name} // '') : '',
        $server ? ($server->{tag} // $server->{chatnet} // '') : '',
    );
}

sub matrix_redraw_prompt_context {
    return if $matrix_unloading;
    # One focus change may emit several related Irssi signals. Paint only the
    # footer, prompt and topic when their real context changed.
    my $signature = matrix_active_prompt_signature();
    return if $signature eq $matrix_prompt_context_signature;
    $matrix_prompt_context_signature = $signature;

    my $bottom_rows_pending = defined($matrix_bottom_rows_redraw_tag) ? 1 : 0;
    if ($bottom_rows_pending) {
        Irssi::timeout_remove($matrix_bottom_rows_redraw_tag);
        undef $matrix_bottom_rows_redraw_tag;
        $matrix_bottom_rows_prompt_pending = 0;
    }

    matrix_with_frozen_refresh(sub {
        Irssi::statusbar_items_redraw('matrix_prompt_context');
        Irssi::statusbar_items_redraw('matrix_sidebar_topic');
        Irssi::statusbar_items_redraw('matrix_sidebar_blue_separator');

    });
    matrix_schedule_cursor_reassert();
}

sub matrix_apply_dynamic_prompt {
    return if $matrix_unloading;
    return unless Irssi::settings_get_bool('network_statusbars_enabled');
    Irssi::command(
        '^statusbar modify -nodisable -type window -placement bottom '
        . '-position 100 -visible active prompt'
    );

    # Rebuild the prompt bar in one deterministic order. The special input item
    # can expand to the full terminal width; if a later config/theme reread moves
    # it before our label, that label appears at the far right. Removing and
    # re-adding all prompt-row items guarantees the same terminal prompt on
    # channels, QUERYs and Status: matrix_prompt_context, input.
    for my $item (qw(prompt prompt_empty matrix_prompt_context input)) {
        Irssi::command('^statusbar removeitem ' . $item . ' prompt');
    }

    Irssi::command(
        '^statusbar additem -alignment left -priority 1000 '
        . 'matrix_prompt_context prompt'
    );
    Irssi::command(
        '^statusbar additem -alignment left -after matrix_prompt_context '
        . '-priority 10 input prompt'
    );

    Irssi::statusbar_items_redraw('matrix_prompt_context');
    Irssi::command('^redraw');
    matrix_schedule_cursor_reassert();
}

Irssi::statusbar_item_register(
    'matrix_prompt_context',
    0,
    'matrix_prompt_context'
);

for my $signal (
    'window changed',
    'window changed automatic',
    'window item changed',
    'window item server changed',
    'server connected',
    'server disconnected',
) {
    Irssi::signal_add_last($signal, 'matrix_redraw_prompt_context');
}

# Flexible black filler. It expands into every otherwise unused statusbar cell,
# preventing the active theme's default background from showing through.
sub matrix_dark_fill {
    my ($item, $get_size_only) = @_;
    return unless $item;

    if ($get_size_only) {
        $item->{min_size} = 0;
        $item->{max_size} = 4096;
        return;
    }

    my $width = defined($item->{size}) ? int($item->{size}) : 0;
    return if $width <= 0;

    $item->default_handler(0, '%0%W' . (' ' x $width), '', 1);
}

# A statusbar containing only matrix_dark_fill can be assigned width zero,
# because that generic filler intentionally has min_size=0. This dedicated
# item reserves at least one cell, forcing Irssi to keep a real one-line black
# separator between the scrolling chat area and the clock/Act rows.
sub matrix_sidebar_blank_row {
    my ($item, $get_size_only) = @_;
    return unless $item;

    if ($get_size_only) {
        $item->{min_size} = 1;
        $item->{max_size} = 4096;
        return;
    }

    my $width = defined($item->{size}) ? int($item->{size}) : 1;
    $width = 1 if $width < 1;
    $item->default_handler(0, '%0%W' . (' ' x $width), '', 1);
}

# Full-width blue context line above the clock. Like the
# blank row, it reserves a real cell even when the bar contains no other items.
sub matrix_sidebar_blue_separator {
    my ($item, $get_size_only) = @_;
    return unless $item;

    if ($get_size_only) {
        $item->{min_size} = 1;
        $item->{max_size} = 4096;
        return;
    }

    my $width = defined($item->{size}) ? int($item->{size}) : 1;
    $width = 1 if $width < 1;
    # Match the clock's one-cell left inset. Context belongs on this blue
    # separator; its surrounding blank rows and the clock keep their positions.
    my $label = matrix_blue_bar_text($width);
    $item->default_handler(0, '%4%W' . pmu_safe_status_text($label)
        . (' ' x ($width - matrix_text_width($label))), '', 1);
}

# The topic row has two explicit meanings and never reuses data from the item
# which was active previously:
#   CHANNEL -> the channel topic
#   QUERY   -> the latest known nick!ident@host of that correspondent
# Incoming PM signals contain the authoritative ident@host. Cache only a small
# bounded set in memory; do not issue WHOIS requests merely to paint this row.
my %matrix_query_hostmasks;
my $MATRIX_QUERY_HOSTMASK_LIMIT = 512;

sub matrix_query_hostmask_key {
    my ($server, $nick) = @_;
    return '' unless $server && defined($nick) && length($nick);
    my $tag = $server->{tag} // '';
    return '' unless length $tag;
    return lc($tag) . "\0" . matrix_irc_casefold($server, $nick);
}

sub matrix_query_hostmask_prune {
    return if scalar(keys %matrix_query_hostmasks)
        <= $MATRIX_QUERY_HOSTMASK_LIMIT;

    my @oldest = sort {
        ($matrix_query_hostmasks{$a}{seen} // 0)
            <=> ($matrix_query_hostmasks{$b}{seen} // 0)
    } keys %matrix_query_hostmasks;
    while (scalar(keys %matrix_query_hostmasks)
        > $MATRIX_QUERY_HOSTMASK_LIMIT && @oldest) {
        delete $matrix_query_hostmasks{shift @oldest};
    }
}

sub matrix_query_hostmask_remember {
    my ($server, $nick, $address) = @_;
    return if $matrix_unloading;
    return unless $server && defined($nick) && length($nick);
    return unless defined($address) && length($address);

    $nick = matrix_terminal_plain_text($nick);
    $address = matrix_terminal_plain_text($address);
    $nick =~ s/\s+//g;
    $address =~ s/\s+//g;
    return unless length($nick) && length($address);

    $nick = substr($nick, 0, 128);
    $address = substr($address, 0, 512);
    my $key = matrix_query_hostmask_key($server, $nick);
    return unless length $key;

    $matrix_query_hostmasks{$key} = {
        nick    => $nick,
        address => $address,
        seen    => time(),
    };
    matrix_query_hostmask_prune();
    Irssi::statusbar_items_redraw('matrix_sidebar_topic');
}

sub matrix_query_hostmask_for_item {
    my ($query) = @_;
    return '' unless $query && $query->{server};

    my $nick = matrix_terminal_plain_text(
        $query->{visible_name} || $query->{name} || ''
    );
    return '' unless length $nick;

    my $key = matrix_query_hostmask_key($query->{server}, $query->{name} // $nick);
    my $entry = length($key) ? $matrix_query_hostmasks{$key} : undef;
    my $address = $entry ? ($entry->{address} // '') : '';

    # Some Irssi builds expose address directly on QUERY. Use it only as a
    # fallback; the message signal remains the portable source.
    if (!length($address) && defined($query->{address})) {
        $address = matrix_terminal_plain_text($query->{address});
        $address =~ s/\s+//g;
    }

    return length($address) ? $nick . '!' . $address : $nick;
}

sub matrix_query_hostmask_forget {
    return if $matrix_unloading;
    my ($server, $nick) = @_;
    my $key = matrix_query_hostmask_key($server, $nick);
    delete $matrix_query_hostmasks{$key} if length $key;
    Irssi::statusbar_items_redraw('matrix_sidebar_topic');
}

sub matrix_query_hostmask_nick_changed {
    return if $matrix_unloading;
    my ($server, $new_nick, $old_nick, $address) = @_;
    return unless $server && defined($new_nick) && defined($old_nick);

    my $old_key = matrix_query_hostmask_key($server, $old_nick);
    my $entry = length($old_key)
        ? delete $matrix_query_hostmasks{$old_key}
        : undef;
    my $known_address = defined($address) && length($address)
        ? $address
        : ($entry ? $entry->{address} : '');
    matrix_query_hostmask_remember(
        $server, $new_nick, $known_address,
    ) if defined($known_address) && length($known_address);
    Irssi::statusbar_items_redraw('matrix_sidebar_topic');
}

sub matrix_query_hostmask_server_disconnected {
    return if $matrix_unloading;
    my ($server) = @_;
    return unless $server;
    my $tag = lc($server->{tag} // '');
    return unless length $tag;
    my $prefix = $tag . "\0";
    delete $matrix_query_hostmasks{$_}
        for grep { index($_, $prefix) == 0 } keys %matrix_query_hostmasks;
    Irssi::statusbar_items_redraw('matrix_sidebar_topic');
}

sub matrix_topic_changed {
    return if $matrix_unloading;
    my ($channel) = @_;
    my $window = Irssi::active_win();
    my $active = $window ? $window->{active} : undef;
    return unless $channel && $active && $channel->{_irssi}
        && ($active->{_irssi} // 0) == $channel->{_irssi};
    Irssi::statusbar_items_redraw('matrix_sidebar_topic');
}
Irssi::signal_add_last('channel topic changed', 'matrix_topic_changed');

# Topic/QUERY-hostmask item on the original Irssi blue statusbar background.
# It paints every cell so the previous black replacement cannot remain visible.
sub matrix_sidebar_topic {
    my ($item, $get_size_only) = @_;
    return unless $item;

    if ($get_size_only) {
        $item->{min_size} = 0;
        $item->{max_size} = 4096;
        return;
    }

    my $window = Irssi::active_win();
    if (defined &netbar_sidebar_is_window
        && $window
        && netbar_sidebar_is_window($window)
        && defined &netbar_sidebar_content_window) {
        $window = netbar_sidebar_content_window();
    }

    my $active = $window ? $window->{active} : undef;
    my $type = $active ? uc($active->{type} // '') : '';
    my $is_query = $active
        && ($type eq 'QUERY' || ref($active) =~ /Query/i);
    my $is_channel = $active
        && ($type eq 'CHANNEL' || ref($active) =~ /Channel/i);

    my $topic_plain = '';
    if ($is_query) {
        $topic_plain = matrix_query_hostmask_for_item($active);
    }
    elsif ($is_channel && defined($active->{topic})) {
        $topic_plain = $active->{topic};
    }
    $topic_plain = matrix_terminal_plain_text($topic_plain);

    my $width = defined($item->{size}) ? int($item->{size}) : 0;
    my $topic_room = $width > 0 ? $width - 1 : 0;
    $topic_room = 0 if $topic_room < 0;
    $topic_plain = matrix_text_truncate_cells($topic_plain, $topic_room)
        if $width > 0 && matrix_text_width($topic_plain) > $topic_room;
    my $padding = $width - 1 - matrix_text_width($topic_plain);
    $padding = 0 if $padding < 0;

    # Keep the one-cell topic indent while restoring the native blue bar.
    $item->default_handler(
        0,
        '%4%W$0-',
        ' ' . $topic_plain . (' ' x $padding),
        1,
    );
}

Irssi::statusbar_item_register(
    'matrix_sidebar_topic',
    0,
    'matrix_sidebar_topic',
);
Irssi::signal_add_last(
    'server disconnected',
    'matrix_query_hostmask_server_disconnected',
);


sub matrix_dark_tick {
    return if $matrix_unloading;
    matrix_visual_sync();
    netbar_sidebar_buffer_watch() if defined &netbar_sidebar_buffer_watch;

    # Update only the separate clock item. PM rows and the input prompt are not
    # repainted by this timer, so a seconds clock cannot start a cursor redraw
    # loop after GNU screen attach or an incoming message.
    matrix_with_frozen_refresh(sub {
        Irssi::statusbar_items_redraw('matrix_sidebar_clock');
    }) if Irssi::settings_get_bool('network_statusbars_enabled');

    # Geometry remains a read-mostly watchdog and schedules a repaint only
    # after a real terminal/split size change.
    netbar_sidebar_resize_watchdog()
        if defined &netbar_sidebar_resize_watchdog;
}

# PM display is shared by sidebar rows and keyboard navigation. Network
# identity stays in the entry/hitbox; the parent network header supplies it.
sub matrix_sidebar_render_activity_entry {
    my ($entry, $max_plain_length) = @_;
    return undef unless $entry;
    $max_plain_length = 4096 unless defined($max_plain_length) && $max_plain_length > 0;
    my $number_width = matrix_text_width(($entry->{refnum} // '') . '/');
    my $age = $entry->{unread} ? matrix_pm_age_label($entry->{last_incoming}) : '';
    my $notice = $entry->{unread}
        ? ' (' . ($entry->{unread_count} || 1) . ')' : '';
    my $age_suffix = length($age) ? ' · ' . $age : '';
    $age_suffix = '' if $max_plain_length - matrix_text_width($notice . $age_suffix) < $number_width + 1;
    $notice = '' if $max_plain_length - matrix_text_width($notice . $age_suffix) < $number_width + 1;
    my $suffix = $notice . $age_suffix;
    my $rendered = pmu_render_stack_entry(
        $entry, $max_plain_length - matrix_text_width($suffix),
    );
    return undef unless $rendered;
    if ($entry->{unread} && defined($entry->{refnum})) {
        my $prefix = int($entry->{refnum}) . '/';
        if (index($rendered->{plain}, $prefix) == 0) {
            $rendered->{text} = '%W' . $prefix . '%M'
                . pmu_safe_status_text(substr($rendered->{plain}, length($prefix))) . '%W';
        }
    }
    $rendered->{plain} .= $suffix;
    $rendered->{text} .= '%M' . $notice . '%K' . $age_suffix . '%W';
    return $rendered;
}

sub matrix_sidebar_activity_data {
    my @all;
    if (defined(&netbar_connected_networks)
        && defined(&pmu_network_stack_entries)
        && defined(&pmu_render_stack_entry)) {
        for my $network (netbar_connected_networks()) {
            for my $entry (pmu_network_stack_entries($network)) {
                next unless lc(netbar_network_name_for_server($entry->{query}->{server})) eq lc($network);
                my $rendered = matrix_sidebar_render_activity_entry(
                    $entry,
                    4096,
                );
                next unless $rendered && length($rendered->{plain} // '');
                push @all, {
                    plain            => $rendered->{plain},
                    text             => $rendered->{text},
                    is_unread        => $rendered->{unread} ? 1 : 0,
                    type             => 'QUERY',
                    tag              => $rendered->{tag} // '',
                    name             => $rendered->{nick} // '',
                    sidebar_activity => 1,
                    immediate_close  => 0,
                    source_entry     => $entry,
                };
            }
        }
    }

    return { all => \@all, total => scalar @all };
}

sub matrix_pm_entries_for_network {
    my ($network) = @_;
    return grep {
        lc(netbar_network_name_for_server($_->{query}->{server})) eq lc($network)
    } pmu_network_stack_entries($network);
}

sub matrix_pm_page_data {
    my ($network) = @_;
    my @entries = matrix_pm_entries_for_network($network);
    my $total = scalar @entries;
    my $max = $total > $MATRIX_PM_PAGE_SIZE ? $total - $MATRIX_PM_PAGE_SIZE : 0;
    my $offset = $matrix_pm_page_offset{lc $network} // 0;
    $offset = $max if $offset > $max;
    $offset = 0 if $offset < 0;
    $matrix_pm_page_offset{lc $network} = $offset;
    my @visible = @entries ? @entries[$offset .. ($total - 1 < $offset + $MATRIX_PM_PAGE_SIZE - 1
        ? $total - 1 : $offset + $MATRIX_PM_PAGE_SIZE - 1)] : ();
    return { entries => \@visible, total => $total, before => $offset,
        after => $total - $offset - scalar(@visible) };
}

sub matrix_pm_scroll_network {
    my ($network, $direction) = @_;
    return unless defined($network) && length($network);
    my $page = matrix_pm_page_data($network);
    return if $direction < 0 ? !$page->{before} : !$page->{after};
    $matrix_pm_page_offset{lc $network} += $direction < 0 ? -1 : 1;
    netbar_schedule_refresh();
}

# Keyboard navigation includes all queries, even those beyond the ten-row page.
sub matrix_sidebar_pm_reveal_query_entry {
    my ($entry) = @_;
    return unless $entry && $entry->{query} && $entry->{query}->{server};
    my $network = netbar_network_name_for_server($entry->{query}->{server});
    my @entries = matrix_pm_entries_for_network($network);
    my $page = matrix_pm_page_data($network);
    my $key = pmu_query_key_from_parts($entry->{tag}, $entry->{nick});
    for my $index (0 .. $#entries) {
        next unless pmu_query_key_from_parts($entries[$index]->{tag}, $entries[$index]->{nick}) eq $key;
        if ($index < $page->{before} || $index >= $page->{before} + $MATRIX_PM_PAGE_SIZE) {
            $matrix_pm_page_offset{lc $network} = $index < $page->{before}
                ? $index : $index - $MATRIX_PM_PAGE_SIZE + 1;
        }
        last;
    }
    netbar_sidebar_follow_active();
}

# Fixed-width clock on its own black footer row.
# The complete clock is bright white. Message timestamps keep their separate
# dark-bracket/bright-time palette in the normal Irssi message formats.
sub matrix_sidebar_clock {
    my ($item, $get_size_only) = @_;
    return unless $item;

    my $shown_time = strftime('%H:%M:%S', localtime());
    $shown_time =~ s/%/%%/g;
    my $clock_plain = ' [' . $shown_time . ']';

    if ($get_size_only) {
        $item->{min_size} = matrix_text_width($clock_plain);
        $item->{max_size} = matrix_text_width($clock_plain);
        return;
    }

    my $clock_text = '%0%W [%W' . $shown_time . '%W]';
    $item->default_handler(
        0,
        $clock_text,
        '',
        1,
    );
}

sub matrix_sidebar_context_label {
    my $window = Irssi::active_win();
    return 'Status' unless $window;
    my $active = $window->{active};
    my $server = $active && $active->{server}
        ? $active->{server} : $window->{active_server};
    my $network = $server ? (netbar_server_tag($server)
        || netbar_network_name_for_server($server)) : '';
    my $target = $active && length($active->{name} // '')
        ? $active->{name} : 'Status';
    return matrix_terminal_plain_text(
        length($network) ? $network . ' / ' . $target : $target,
    );
}

Irssi::statusbar_item_register('matrix_dark_fill', 0, 'matrix_dark_fill');
Irssi::statusbar_item_register(
    'matrix_sidebar_blue_separator', 0, 'matrix_sidebar_blue_separator',
);
Irssi::statusbar_item_register(
    'matrix_sidebar_blank_row',
    0,
    'matrix_sidebar_blank_row',
);
Irssi::statusbar_item_register(
    'matrix_sidebar_clock',
    0,
    'matrix_sidebar_clock',
);
# ============================================================================
# pm_unread.pl
# ============================================================================
{
# Unread entries are stored as: network-tag + nick.
my %unread;

sub matrix_pm_unread_total {
    # One unread entry per server tag and nick. Repeated messages update the
    # per-conversation message count without increasing the blue-bar total.
    return scalar keys %unread;
}
my %pmu_focus_timers;
my $pmu_previous_autocreate_own_query;
my $pmu_gc_timer;
my $pmu_final_redraw_timer;
my %last_query_by_tag;  # one actually viewed/sent QUERY remembered per network tag


sub pmu_server_tag {
    my ($server) = @_;
    return '' unless defined $server;
    return defined $server->{tag} ? $server->{tag} : '';
}

sub pmu_normalise_nick {
    my ($nick, $server) = @_;
    return matrix_irc_casefold($server, $nick);
}

sub pmu_unread_key {
    my ($server, $nick) = @_;
    # Server tags are case-insensitive identifiers. Use the same canonical key
    # as the Act renderer so reconnects or case-only tag spelling changes cannot
    # leave an unread marker which a click cannot find and remove.
    return lc(pmu_server_tag($server)) . "\0" . pmu_normalise_nick($nick, $server);
}

sub pmu_is_own_nick {
    my ($server, $nick) = @_;
    return 0 unless defined $server && defined $nick;

    my $own_nick = defined $server->{nick} ? $server->{nick} : '';
    return 0 if $own_nick eq '';

    return pmu_normalise_nick($nick, $server) eq pmu_normalise_nick($own_nick, $server);
}

sub pmu_is_query {
    my ($item) = @_;
    return 0 unless defined $item;

    return 1 if defined $item->{type} && uc($item->{type}) eq 'QUERY';
    return ref($item) =~ /Query/i ? 1 : 0;
}

sub pmu_item_matches_query {
    my ($item, $server, $nick) = @_;
    return 0 unless pmu_is_query($item);
    return 0 unless defined $item->{name};
    return 0 unless pmu_server_tag($item->{server}) eq pmu_server_tag($server);

    return pmu_normalise_nick($item->{name}, $server) eq pmu_normalise_nick($nick, $server);
}

sub pmu_query_is_visible {
    my ($server, $nick) = @_;
    my $window = Irssi::active_win();
    return 0 unless defined $window;
    return 0 if matrix_window_scrolled($window);
    return pmu_item_matches_query($window->{active}, $server, $nick);
}

sub pmu_query_key_from_parts {
    my ($tag, $nick) = @_;
    $tag  = '' unless defined $tag;
    $nick = '' unless defined $nick;
    my $server = length($tag) ? Irssi::server_find_tag($tag) : undef;
    return lc($tag) . "\0" . pmu_normalise_nick($nick, $server);
}

sub pmu_query_key_from_item {
    my ($item) = @_;
    return '' unless pmu_is_query($item) && defined($item->{name});
    return pmu_query_key_from_parts(pmu_server_tag($item->{server}), $item->{name});
}

sub pmu_find_query {
    my ($tag, $nick) = @_;
    return undef unless defined($nick) && length($nick);

    if (defined($tag) && length($tag)) {
        my $server = Irssi::server_find_tag($tag);
        return undef unless $server;
        return eval { $server->query_find($nick) };
    }

    return eval { Irssi::query_find($nick) };
}

sub pmu_network_name_for_tag {
    my ($tag) = @_;
    $tag = '' unless defined $tag;

    my $server = length($tag) ? Irssi::server_find_tag($tag) : undef;
    return netbar_network_name_for_server($server) if $server;
    return $tag;
}

sub pmu_tag_matches_network {
    my ($tag, $network) = @_;
    return 1 unless defined($network) && length($network);

    $tag = '' unless defined $tag;
    return 1 if lc($tag) eq lc($network);

    my $resolved = pmu_network_name_for_tag($tag);
    return length($resolved) && lc($resolved) eq lc($network) ? 1 : 0;
}

# Remember one QUERY per network, but only after it was actually displayed.
# Merely receiving a PM or automatically creating a background QUERY never
# changes the remembered conversation for that network.
sub pmu_remember_query_item {
    my ($item) = @_;
    return 0 unless pmu_is_query($item);
    return 0 unless defined($item->{name}) && length($item->{name});

    my $window = eval { $item->window() };
    my $active = Irssi::active_win();
    return 0 unless $window && $active;
    return 0 unless defined($window->{refnum}) && defined($active->{refnum});
    return 0 unless int($window->{refnum}) == int($active->{refnum});

    my $tag = pmu_server_tag($item->{server});
    my $tag_key = lc($tag);
    my $new_key = pmu_query_key_from_parts($tag, $item->{name});
    my $old = $last_query_by_tag{$tag_key};
    my $old_key = $old
        ? pmu_query_key_from_parts($old->{tag}, $old->{nick})
        : '';

    $last_query_by_tag{$tag_key} = {
        tag  => $tag,
        nick => $item->{name},
        seen => time(),
    };

    pmu_redraw_statusbar() if $new_key ne $old_key;
    return 1;
}

# Compatibility helper retained for external callers; not the active panel renderer.
sub pmu_remember_active_query {
    my ($window, $item) = @_;
    $window = Irssi::active_win() unless $window;
    return 0 unless $window;
    $item = $window->{active} unless $item;
    return pmu_remember_query_item($item);
}

sub pmu_forget_query_item {
    my ($item) = @_;
    return 0 unless pmu_is_query($item) && $item->{server};

    my $tag = pmu_server_tag($item->{server});
    my $tag_key = lc($tag);
    my $last = $last_query_by_tag{$tag_key};
    return 0 unless $last;

    my $item_key = pmu_query_key_from_item($item);
    return 0 if $item_key eq '';

    my $last_key = pmu_query_key_from_parts($last->{tag}, $last->{nick});
    return 0 unless $item_key eq $last_key;

    delete $last_query_by_tag{$tag_key};
    pmu_redraw_statusbar();
    return 1;
}

# Return the newest live remembered QUERY for one network. Destroyed or
# renamed-away attack windows are removed immediately, so a MASS flood cannot
# leave stale names beside a network header.
sub pmu_last_query_entry {
    my ($wanted_network) = @_;

    my @tag_keys = sort {
        ($last_query_by_tag{$b}->{seen} || 0)
            <=> ($last_query_by_tag{$a}->{seen} || 0)
    } keys %last_query_by_tag;

    for my $tag_key (@tag_keys) {
        my $last = $last_query_by_tag{$tag_key};
        next unless $last;
        next unless pmu_tag_matches_network($last->{tag}, $wanted_network);

        my $query = pmu_find_query($last->{tag}, $last->{nick});
        if (!$query) {
            delete $last_query_by_tag{$tag_key};
            next;
        }

        $last->{nick} = $query->{name}
            if defined($query->{name}) && length($query->{name});

        return {
            tag   => $last->{tag},
            nick  => $last->{nick},
            query => $query,
            first => $last->{seen} || 0,
        };
    }

    return undef;
}

sub pmu_unread_number_colour {
    # Unread PM numbers use one steady activity colour, like Irssi's Act item.
    return '%M';
}

sub pmu_redraw_statusbar {
    return if $matrix_unloading;
    matrix_alerts_redraw();
    # All PM rows now live in the network panel. Its persistent buffer patches
    # only changed lines, including a minute-age update or acknowledgement.
    netbar_schedule_refresh() if defined &netbar_schedule_refresh;
}

sub pmu_safe_status_text {
    my ($text) = @_;
    $text = '' unless defined $text;
    $text =~ s/[\x00-\x1f\x7f-\x9f]//g;
    # Curly braces are Irssi theme-control delimiters. Keep hostile or unusual
    # nicks from being interpreted as a format block inside a statusbar row.
    $text =~ tr/{}/()/;
    $text =~ s/%/%%/g;
    return $text;
}

# Return the Irssi window number containing the query.  The lookup is done
# dynamically so the displayed number follows /WINDOW MOVE and automatic
# window renumbering.
sub pmu_query_window_refnum {
    my ($entry) = @_;
    return undef unless defined $entry && defined $entry->{nick};

    my $query;

    if (defined $entry->{tag} && $entry->{tag} ne '') {
        my $server = Irssi::server_find_tag($entry->{tag});
        $query = eval { $server->query_find($entry->{nick}) }
            if defined $server;
    }
    else {
        $query = eval { Irssi::query_find($entry->{nick}) };
    }

    if (defined $query) {
        my $window = eval { $query->window() };
        if (defined $window && defined $window->{refnum}) {
            return int($window->{refnum});
        }
    }

    # Used mainly by /pmtest when no real query exists yet.
    return $entry->{refnum_hint}
        if defined $entry->{refnum_hint} && $entry->{refnum_hint} =~ /^\d+$/;

    return undef;
}

# Return the real QUERY object for an unread entry. Unlike
# pmu_query_window_refnum(), this never uses a diagnostic refnum hint.
sub pmu_entry_query {
    my ($entry) = @_;
    return undef unless defined $entry && defined $entry->{nick};

    if (defined $entry->{tag} && $entry->{tag} ne '') {
        my $server = Irssi::server_find_tag($entry->{tag});
        return undef unless $server;
        return eval { $server->query_find($entry->{nick}) };
    }

    return eval { Irssi::query_find($entry->{nick}) };
}

# Remove alerts whose QUERY was already destroyed. The query-destroyed signal
# handles the normal case immediately; this timer is a fallback for Irssi builds
# where a rapid create/destroy burst can outrun the signal ordering.
sub pmu_prune_stale_entries {
    my $orphan_seconds = Irssi::settings_get_int('pm_unread_orphan_seconds');
    $orphan_seconds = 5 if !defined($orphan_seconds) || $orphan_seconds < 1;

    my $now = time();
    my $removed = 0;
    for my $key (keys %unread) {
        my $entry = $unread{$key};
        next if pmu_entry_query($entry);
        next if defined($entry->{refnum_hint}); # keep explicit /pmtest entries
        next if $now - ($entry->{first} // $now) < $orphan_seconds;
        delete $unread{$key};
        delete $matrix_pm_last_incoming{$key};
        delete $matrix_pm_recent{$key};
        $removed++;
    }

    pmu_redraw_statusbar() if $removed;
}

# Build clickable ranges for the QUERY labels which survived width trimming.
# Coordinates are relative to the beginning of the plain PM fragment.
sub pmu_fragment_click_ranges {
    my ($plain, $labels_ref) = @_;
    $plain = '' unless defined $plain;
    $labels_ref = [] unless ref($labels_ref) eq 'ARRAY';

    my @ranges;
    my $cursor = 0;
    for my $label (@$labels_ref) {
        next unless $label && defined($label->{plain}) && length($label->{plain});
        my $start = index($plain, $label->{plain}, $cursor);
        next if $start < 0;

        push @ranges, {
            start => $start,
            end   => $start + length($label->{plain}) - 1,
            tag   => $label->{tag} // '',
            nick  => $label->{nick} // '',
        };
        $cursor = $start + length($label->{plain});
    }
    return \@ranges;
}

sub pmu_fragment_for_network {
    my ($wanted_network, $max_plain_length) = @_;
    $max_plain_length = 4096
        if !defined($max_plain_length) || $max_plain_length < 1;

    my $last = pmu_last_query_entry($wanted_network);
    my $last_key = $last
        ? pmu_query_key_from_parts($last->{tag}, $last->{nick})
        : '';

    my @entries = sort {
        $a->{first} <=> $b->{first}
            || pmu_normalise_nick($a->{nick}) cmp pmu_normalise_nick($b->{nick})
    } grep {
        pmu_tag_matches_network($_->{tag}, $wanted_network)
    } values %unread;

    my $last_is_unread = 0;
    my @other_entries;
    for my $entry (@entries) {
        my $key = pmu_query_key_from_parts($entry->{tag}, $entry->{nick});
        if ($last && $key eq $last_key) {
            $last_is_unread = 1;
            next;
        }
        push @other_entries, $entry;
    }

    return ('', '', []) unless $last || @other_entries;

    my $last_label;
    if ($last) {
        my $nick = pmu_safe_status_text($last->{nick});
        my $window = eval { $last->{query}->window() };
        my $refnum = $window && defined($window->{refnum})
            ? int($window->{refnum})
            : undef;

        if (defined $refnum) {
            my $number_colour = $last_is_unread
                ? pmu_unread_number_colour()
                : '%w';
            my $last_plain = $refnum . '/' . $nick;
            $last_label = {
                plain => $last_plain,
                # Only the unread window number lights steadily in the
                # activity colour. /nick stays white.
                text  => $number_colour . $refnum . '%w/' . $nick . '%K',
                tag   => $last->{tag} // '',
                nick  => $last->{nick} // '',
            };
        }
        else {
            $last_label = {
                plain => $nick,
                text  => '%W' . $nick . '%K',
                tag   => $last->{tag} // '',
                nick  => $last->{nick} // '',
            };
        }
    }

    my $max_nicks = Irssi::settings_get_int('pm_unread_max_nicks');
    $max_nicks = 5 if !defined $max_nicks || $max_nicks < 1;

    my @labels;
    my $limit = @other_entries < $max_nicks
        ? scalar(@other_entries)
        : $max_nicks;
    for my $index (0 .. $limit - 1) {
        my $entry = $other_entries[$index];
        my $nick = pmu_safe_status_text($entry->{nick});
        my $refnum = pmu_query_window_refnum($entry);
        my $number_colour = pmu_unread_number_colour();

        my $plain = defined($refnum) ? $refnum . '/' . $nick : $nick;
        my $text = defined($refnum)
            ? $number_colour . $refnum . '%w/' . $nick . '%K'
            : '%w' . $nick . '%K';

        push @labels, {
            plain => $plain,
            text  => $text,
            tag   => $entry->{tag} // '',
            nick  => $entry->{nick} // '',
        };
    }

    my $hidden = scalar(@other_entries) - scalar(@labels);

    # The remembered QUERY has priority. Unread entries are trimmed from the
    # right first; a compact +N remains when possible. :.: is always gray.
    while (1) {
        my @plain_parts = map { $_->{plain} } @labels;
        my @text_parts  = map { $_->{text}  } @labels;
        push @plain_parts, '+' . $hidden if $hidden > 0;
        push @text_parts,  '%W+' . $hidden . '%K' if $hidden > 0;

        my $unread_plain = join(', ', @plain_parts);
        my $unread_text  = join('%K, ', @text_parts);

        my ($plain, $text) = ('', '');
        if ($last_label && length($unread_plain)) {
            $plain = $last_label->{plain} . ' :.:  ' . $unread_plain;
            $text  = $last_label->{text} . '%K :.:  ' . $unread_text;
        }
        elsif ($last_label) {
            $plain = $last_label->{plain};
            $text  = $last_label->{text};
        }
        else {
            $plain = $unread_plain;
            $text  = $unread_text;
        }

        if (length($plain) <= $max_plain_length) {
            my @visible = ($last_label ? ($last_label) : (), @labels);
            return (
                $text,
                $plain,
                pmu_fragment_click_ranges($plain, \@visible),
            );
        }

        if (@labels) {
            pop @labels;
            $hidden++;
            next;
        }

        # Show the remembered QUERY by itself only when no unread entry would
        # be hidden. New PW always has priority over a merely remembered chat.
        if ($last_label && $hidden == 0
            && length($last_label->{plain}) <= $max_plain_length) {
            my @visible = ($last_label);
            return (
                $last_label->{text},
                $last_label->{plain},
                pmu_fragment_click_ranges($last_label->{plain}, \@visible),
            );
        }
        last;
    }

    # On a very narrow terminal collapse every unread conversation into +N.
    # Include the remembered QUERY when that same QUERY is also unread.
    my $total = scalar(@other_entries) + ($last_is_unread ? 1 : 0);
    my $fallback_plain = '+' . $total;
    my $fallback_text  = '%W+' . $total . '%K';
    return $total > 0 && length($fallback_plain) <= $max_plain_length
        ? ($fallback_text, $fallback_plain, [])
        : ('', '', []);
}

# Compatibility helper retained for external callers; not the active panel renderer.
sub pmu_network_fragment {
    my ($network, $max_plain_length) = @_;
    return pmu_fragment_for_network($network, $max_plain_length);
}

# Return one ordered PM list for a network. Sidebar and keyboard navigation use
# a stable order based on Irssi window numbers. Opening or clicking
# a QUERY must never move it to the first position; only an actual window-number
# change or closing/opening a QUERY may change the visible order.
sub pmu_plain_status_text {
    my ($text) = @_;
    $text = matrix_terminal_plain_text($text);
    $text =~ tr/{}/()/;
    return $text;
}

sub pmu_stack_entry_data {
    my ($query, $is_unread) = @_;
    return undef unless pmu_is_query($query);
    return undef unless $query->{server} && defined($query->{name})
        && length($query->{name});

    my $window = eval { $query->window() };
    return undef unless $window && defined($window->{refnum});

    my $nick = $query->{name};
    my $visible = pmu_query_is_visible($query->{server}, $nick) ? 1 : 0;
    # Visibility is the final authority. Even if an older/stale unread key is
    # still present during the same signal frame, an actively displayed QUERY
    # must never be rendered with the notification colour.
    $is_unread = 0 if $visible;
    my $number_colour = $is_unread
        ? pmu_unread_number_colour()
        : ($visible ? '%W' : '%w');

    return {
        query         => $query,
        tag           => pmu_server_tag($query->{server}),
        nick          => $nick,
        plain_nick    => pmu_plain_status_text($nick),
        last_incoming => $matrix_pm_last_incoming{pmu_unread_key($query->{server}, $nick)},
        refnum        => int($window->{refnum}),
        unread        => $is_unread ? 1 : 0,
        unread_count  => $is_unread ? ($unread{pmu_unread_key($query->{server}, $nick)}->{count} // 0) : 0,
        number_colour => $number_colour,
    };
}

sub pmu_network_stack_entries {
    my ($wanted_network) = @_;

    # Display and keyboard navigation share newest-incoming-first PM order.
    # Acknowledging a query clears its alert without moving it again.
    my @unread_entries = sort {
        $a->{first} <=> $b->{first}
            || pmu_normalise_nick($a->{nick}) cmp pmu_normalise_nick($b->{nick})
    } grep {
        pmu_tag_matches_network($_->{tag}, $wanted_network)
    } values %unread;

    my %unread_by_key = map {
        pmu_query_key_from_parts($_->{tag}, $_->{nick}) => $_
    } @unread_entries;
    my %unread_keys = map { $_ => 1 } keys %unread_by_key;

    # Collect every live QUERY once. Window numbers break ties for queries
    # which have received no unread message during this script session.
    my @queries;
    my %seen;

    for my $query (Irssi::queries()) {
        next unless pmu_is_query($query);
        next unless $query->{server} && defined($query->{name})
            && length($query->{name});
        next unless pmu_tag_matches_network(
            pmu_server_tag($query->{server}),
            $wanted_network,
        );

        my $window = eval { $query->window() };
        next unless $window && defined($window->{refnum});

        my $key = pmu_query_key_from_parts(
            pmu_server_tag($query->{server}),
            $query->{name},
        );
        next if $seen{$key}++;

        push @queries, {
            query  => $query,
            key    => $key,
            refnum => int($window->{refnum}),
            nick   => $query->{name},
        };
    }

    # Defensive fallback for a freshly-created unread entry which is not yet
    # present in Irssi::queries() during this exact signal callback.
    for my $unread_entry (@unread_entries) {
        my $key = pmu_query_key_from_parts(
            $unread_entry->{tag},
            $unread_entry->{nick},
        );
        next if $seen{$key}++;

        my $query = pmu_entry_query($unread_entry);
        next unless $query;
        my $window = eval { $query->window() };
        next unless $window && defined($window->{refnum});

        push @queries, {
            query  => $query,
            key    => $key,
            refnum => int($window->{refnum}),
            nick   => $query->{name},
        };
    }

    @queries = sort {
        ($matrix_pm_recent{$b->{key}} // 0) <=> ($matrix_pm_recent{$a->{key}} // 0)
            || $a->{refnum} <=> $b->{refnum}
            || pmu_normalise_nick($a->{nick}) cmp pmu_normalise_nick($b->{nick})
    } @queries;

    my @result;
    for my $candidate (@queries) {
        my $entry = pmu_stack_entry_data(
            $candidate->{query},
            $unread_keys{$candidate->{key}} ? 1 : 0,
        );
        $entry->{unread_first} =
            $unread_by_key{$candidate->{key}}->{first} || 0
            if $entry && $unread_by_key{$candidate->{key}};
        push @result, $entry if $entry;
    }

    return @result;
}

sub pmu_render_stack_entry {
    my ($entry, $max_plain_length) = @_;
    return undef unless $entry;

    $max_plain_length = 4096
        if !defined($max_plain_length) || $max_plain_length < 1;

    my $prefix_plain = defined($entry->{refnum})
        ? int($entry->{refnum}) . '/'
        : '';
    my $nick_plain = $entry->{plain_nick} // '';
    my $room = $max_plain_length - matrix_text_width($prefix_plain);

    if ($room <= 0) {
        my $plain = matrix_text_truncate_cells($prefix_plain, $max_plain_length);
        return {
            %$entry,
            plain => $plain,
            text  => ($entry->{number_colour} // '%w')
                . pmu_safe_status_text($plain) . '%K',
        };
    }

    if (matrix_text_width($nick_plain) > $room) {
        $nick_plain = $room == 1
            ? '~'
            : matrix_text_truncate_cells($nick_plain, $room - 1) . '~';
    }

    my $plain = $prefix_plain . $nick_plain;
    my $text;
    if (length($prefix_plain)) {
        if ($entry->{unread}) {
            # matrix_sidebar_render_activity_entry() applies the final unread
            # nr/nick palette. Keep this compatibility renderer bright until
            # that wrapper has the complete, possibly truncated nickname.
            $text = pmu_unread_number_colour()
                . int($entry->{refnum}) . '%W/'
                . pmu_safe_status_text($nick_plain) . '%W';
        }
        else {
            # A read QUERY remains listed, but no longer competes visually with
            # a fresh alert. Use normal/dim white for the complete nr/nick.
            $text = '%w' . int($entry->{refnum}) . '%w/'
                . pmu_safe_status_text($nick_plain) . '%W';
        }
    }
    else {
        $text = ($entry->{unread} ? '%W' : '%w')
            . pmu_safe_status_text($nick_plain) . '%W';
    }

    return {
        %$entry,
        plain => $plain,
        text  => $text,
    };
}

# Build compact Act rows. The first configured batch (normally twelve QUERYs)
# is shown initially. Clicking +N expands another batch below the existing
# rows; it never replaces the already visible PMs. Every continuation row starts
# in exactly the same column as the first PM after Act:. At most one batch-size
# worth of real PM entries is placed in a physical row, although a narrow
# terminal may wrap earlier. +N is an extra control and may follow the sixth PM
# when there is enough room.
# Compatibility helper retained for external callers; not the active panel renderer.
sub pmu_inline_rows_for_network {
    my (
        $wanted_network,
        $visible_limit,
        $first_plain_width,
        $continuation_plain_width,
        $max_entries_per_row,
    ) = @_;

    $visible_limit = int($visible_limit // 0);
    $first_plain_width = int($first_plain_width // 0);
    $continuation_plain_width = int($continuation_plain_width // 0);
    $max_entries_per_row = int($max_entries_per_row // 0);
    $max_entries_per_row = 6 if $max_entries_per_row < 1;

    my @entries = pmu_network_stack_entries($wanted_network);
    my $total = scalar @entries;
    return {
        rows          => [],
        total         => 0,
        visible_count => 0,
        hidden_count  => 0,
    } unless $total && $visible_limit > 0
        && $first_plain_width > 0 && $continuation_plain_width > 0;

    my $visible_count = $total < $visible_limit ? $total : $visible_limit;
    my @selected = @entries[0 .. $visible_count - 1];
    my $hidden_count = $total - $visible_count;

    my @rows;
    my $row_width = $first_plain_width;
    my $row_entry_count = 0;
    my $row = {
        plain    => '',
        text     => '',
        segments => [],
    };

    my $finish_row = sub {
        return unless length($row->{plain});
        push @rows, $row;
        $row_width = $continuation_plain_width;
        $row_entry_count = 0;
        $row = {
            plain    => '',
            text     => '',
            segments => [],
        };
    };

    for my $entry (@selected) {
        # The sixth, eleventh, ... real PM always starts a new aligned row even
        # when an unusually wide terminal could still fit it on the previous one.
        $finish_row->() if $row_entry_count >= $max_entries_per_row;

        my $separator_plain = length($row->{plain}) ? ', ' : '';
        my $separator_text  = length($row->{plain}) ? '%K, %w' : '';

        # Render against a complete row first. This lets us distinguish a PM
        # which genuinely belongs on the next line from one which is itself
        # longer than the terminal and must be shortened.
        my $rendered = pmu_render_stack_entry($entry, $row_width);
        next unless $rendered && length($rendered->{plain});

        my $available = $row_width - matrix_text_width($row->{plain})
            - matrix_text_width($separator_plain);
        if (length($row->{plain})
            && matrix_text_width($rendered->{plain}) > $available) {
            $finish_row->();
            $separator_plain = '';
            $separator_text = '';
            $rendered = pmu_render_stack_entry($entry, $row_width);
            next unless $rendered && length($rendered->{plain});
        }

        my $segment_start = matrix_text_width($row->{plain})
            + matrix_text_width($separator_plain);
        $row->{plain} .= $separator_plain . $rendered->{plain};
        $row->{text}  .= $separator_text  . $rendered->{text};
        push @{ $row->{segments} }, {
            x_start => $segment_start,
            x_end   => $segment_start + matrix_text_width($rendered->{plain}) - 1,
            type    => 'QUERY',
            tag     => $rendered->{tag} // '',
            name    => $rendered->{nick} // '',
        };
        $row_entry_count++;
    }

    if ($hidden_count > 0) {
        my $more_plain = '+' . $hidden_count;
        my $more_text  = '%W+' . $hidden_count . '%K';
        my $separator_plain = length($row->{plain}) ? '  ' : '';
        my $separator_text  = length($row->{plain}) ? '%K  ' : '';

        if (length($row->{plain})
            && matrix_text_width($separator_plain) + matrix_text_width($more_plain)
                > $row_width - matrix_text_width($row->{plain})) {
            $finish_row->();
            $separator_plain = '';
            $separator_text = '';
        }

        my $segment_start = matrix_text_width($row->{plain})
            + matrix_text_width($separator_plain);
        $row->{plain} .= $separator_plain . $more_plain;
        $row->{text}  .= $separator_text  . $more_text;
        push @{ $row->{segments} }, {
            x_start => $segment_start,
            x_end   => $segment_start + matrix_text_width($more_plain) - 1,
            type    => 'PM_MORE',
            network => $wanted_network,
        };
    }

    $finish_row->();

    return {
        rows          => \@rows,
        total         => $total,
        visible_count => $visible_count,
        hidden_count  => $hidden_count,
    };
}

# Keyboard navigation uses the complete logical list, including conversations
# represented by +N. It follows the same stable window-number order as Act:, so
# selecting a QUERY never changes its position.
sub pmu_navigation_entries_for_network {
    my ($wanted_network) = @_;

    # Follow the exact order rendered in Act:. The previous implementation
    # sorted this list again by window number, so Alt+arrows could jump in a
    # different order than the PMs visible under the network header.
    return pmu_network_stack_entries($wanted_network);
}

# Compatibility renderer for an old standalone PM item. It follows the active
# network, while the real visible indicators are now attached to all headers.
sub pmu_clock_fragment {
    my ($max_plain_length) = @_;
    my $window = Irssi::active_win();
    my $item = $window ? $window->{active} : undef;
    my $server = $item && $item->{server} ? $item->{server} : undef;
    $server = $window->{active_server} if !$server && $window && $window->{active_server};
    $server = eval { Irssi::active_server() } if !$server;

    my $network = $server
        ? netbar_network_name_for_server($server)
        : undef;
    return pmu_fragment_for_network($network, $max_plain_length);
}

sub pmu_statusbar_text {
    my ($text, $plain) = pmu_clock_fragment(4096);
    return '' unless length($plain);
    return '%0%W ' . $text . ' %0%W';
}

sub pmu_draw_pm_unread {
    my ($item, $get_size_only) = @_;
    return unless defined $item;

    $get_size_only = 0 unless defined $get_size_only;
    my $text = pmu_statusbar_text();

    # Hide the item completely when there are no unread private messages.
    # Calling default_handler() with an empty value can render a literal "0"
    # on some Irssi/Perl builds, so collapse the item to zero width instead.
    if ($text eq '') {
        $item->{min_size} = 0;
        $item->{max_size} = 0;
        return;
    }

    $item->default_handler($get_size_only ? 1 : 0, $text, '', 1);
}

sub pmu_clear_query_item {
    my ($item) = @_;
    return 0 unless pmu_is_query($item);
    return 0 unless defined $item->{name};

    my $key = pmu_unread_key($item->{server}, $item->{name});
    return 0 unless exists $unread{$key};

    delete $unread{$key};
    delete $matrix_pm_last_incoming{$key};
    pmu_redraw_statusbar();
    return 1;
}

# Clear a stale unread marker even when Irssi did not create a QUERY object.
# The flood guard uses this after stopping a raw PRIVMSG before normal delivery.
sub pmu_clear_query_name {
    my ($server, $nick) = @_;
    return 0 unless $server && defined($nick) && length($nick);

    my $key = pmu_unread_key($server, $nick);
    return 0 unless exists $unread{$key};

    delete $unread{$key};
    delete $matrix_pm_last_incoming{$key};
    pmu_redraw_statusbar();
    return 1;
}

sub pmu_add_unread {
    my ($server, $nick, $refnum_hint) = @_;
    return unless defined $nick && $nick ne '';

    my $key = pmu_unread_key($server, $nick);

    if (exists $unread{$key}) {
        $unread{$key}->{count}++;
        $unread{$key}->{nick} = $nick;
        $unread{$key}->{refnum_hint} = $refnum_hint
            if defined $refnum_hint && $refnum_hint =~ /^\d+$/;
    }
    else {
        $unread{$key} = {
            nick        => $nick,
            tag         => pmu_server_tag($server),
            count       => 1,
            first       => time(),
            refnum_hint => (
                defined $refnum_hint && $refnum_hint =~ /^\d+$/
                    ? int($refnum_hint)
                    : undef
            ),
        };
    }

    pmu_redraw_statusbar();
}

sub pmu_focus_timer_key {
    my ($server, $nick) = @_;
    return pmu_server_tag($server) . "\t" . pmu_normalise_nick($nick, $server);
}

sub pmu_focus_pending_query {
    my ($key) = @_;
    my $pending = delete $pmu_focus_timers{$key};
    return unless $pending;
    return unless Irssi::settings_get_bool('pm_focus_sent_messages');

    my $server = Irssi::server_find_tag($pending->{tag});
    return unless $server && $server->{connected};

    my $query = eval { $server->query_find($pending->{nick}) };

    # autocreate_own_query should normally have created it already. This
    # fallback covers slower builds/configurations without touching incoming PMs.
    if (!$query) {
        my $tag = $pending->{tag};
        Irssi::command('query -' . $tag . ' ' . $pending->{nick});
        $query = eval { $server->query_find($pending->{nick}) };
    }
    return unless $query;

    my $window = eval { $query->window() };
    return unless $window && defined($window->{refnum});

    my $active = Irssi::active_win();
    if (!$active || !defined($active->{refnum})
        || int($active->{refnum}) != int($window->{refnum})) {
        Irssi::command('window goto ' . int($window->{refnum}));
    }

    # A window may contain several channels/queries. Activate the exact QUERY
    # instead of only entering its parent window.
    eval { $query->set_active(); };

    # Sending /MSG may automatically create and focus this QUERY. The unread
    # marker was already acknowledged in pmu_signal_own_private(); this delayed
    # step only resolves/focuses the final QUERY object and refreshes Act.
    pmu_remember_query_item($query);
    pmu_redraw_statusbar();
}

sub pmu_schedule_query_focus {
    my ($server, $nick) = @_;
    return unless Irssi::settings_get_bool('pm_focus_sent_messages');
    return unless $server && defined($nick) && length($nick);

    my $key = pmu_focus_timer_key($server, $nick);
    if (my $old = delete $pmu_focus_timers{$key}) {
        Irssi::timeout_remove($old->{timer}) if $old->{timer};
    }

    my $pending = {
        tag   => pmu_server_tag($server),
        nick  => $nick,
        timer => undef,
    };
    $pmu_focus_timers{$key} = $pending;
    $pending->{timer} = Irssi::timeout_add_once(
        200,
        'pmu_focus_pending_query',
        $key,
    );
}

sub pmu_signal_private_message {
    my ($server, $message, $nick, $address, $target) = @_;
    return unless defined $server && defined $nick && $nick ne '';

    # Ignore echoes/backlog lines attributed to our own nickname.
    return if pmu_is_own_nick($server, $nick);

    # Keep the PM header tied to the correspondent who actually supplied this
    # line. Irssi passes address as ident@host, so the topic row can render the
    # complete nick!ident@host without a background WHOIS.
    matrix_query_hostmask_remember($server, $nick, $address)
        if defined &matrix_query_hostmask_remember;

    my $key = pmu_unread_key($server, $nick);
    # A PM arriving in the query currently displayed is already considered read.
    if (pmu_query_is_visible($server, $nick)) {
        if (exists $unread{$key}) {
            delete $unread{$key};
            delete $matrix_pm_last_incoming{$key};
            pmu_redraw_statusbar();
        }
        return;
    }

    $matrix_pm_last_incoming{$key} = time();
    $matrix_pm_recent{$key} = ++$matrix_pm_sequence;
    $matrix_pm_page_offset{lc(netbar_network_name_for_server($server))} = 0;
    netbar_sidebar_reveal_pm($server, $nick);

    # Incoming PMs never steal focus. They remain in the background and make
    # only their Act number stays lit like a channel nick mention until QUERY opens.
    pmu_add_unread($server, $nick);

    # On some Irssi builds the automatic query is created just after the
    # message signal. Redraw once more so its final window number appears.
    Irssi::timeout_remove($pmu_final_redraw_timer)
        if defined $pmu_final_redraw_timer;
    $pmu_final_redraw_timer = Irssi::timeout_add_once(
        100,
        'pmu_final_redraw_statusbar',
        '',
    );
}

sub pmu_final_redraw_statusbar {
    undef $pmu_final_redraw_timer;
    return if $matrix_unloading;
    pmu_redraw_statusbar();
}

sub pmu_signal_own_private {
    my ($server, $message, $target, $original_target) = @_;
    return unless $server && defined($target) && length($target);

    my $focus_sent = Irssi::settings_get_bool('pm_focus_sent_messages');
    my $focus_scheduled = 0;

    # Sending a reply is an explicit acknowledgement, just like clicking the
    # Act entry or entering it with Alt-Left/Alt-Right. /MSG can accept a
    # comma-separated target list: mark every private target as read, but focus
    # only the first one and never treat a channel target as a conversation.
    for my $nick (split /,/, $target) {
        $nick =~ s/^\s+|\s+$//g;
        next if $nick eq '';
        next if eval { $server->ischannel($nick) };

        pmu_clear_query_name($server, $nick);
        if ($focus_sent && !$focus_scheduled) {
            pmu_schedule_query_focus($server, $nick);
            $focus_scheduled = 1;
        }
    }
}

# Any QUERY which is genuinely displayed in the active conversation window is
# read. This deliberately ignores how it became active: mouse, Alt navigation,
# /WINDOW, /MSG focus or another Irssi command all have the same result.
sub pmu_acknowledge_active_query {
    my ($window, $item) = @_;
    my $active_window = Irssi::active_win();
    return 0 unless $active_window;

    $window = $active_window unless $window;
    return 0 unless defined($window->{refnum})
        && defined($active_window->{refnum})
        && int($window->{refnum}) == int($active_window->{refnum});

    $item = $window->{active} unless pmu_is_query($item);
    return 0 unless pmu_is_query($item)
        && $item->{server} && defined($item->{name});
    matrix_sidebar_pm_reveal_query_entry({query => $item,
        tag => pmu_server_tag($item->{server}), nick => $item->{name}});
    return 0 unless pmu_query_is_visible($item->{server}, $item->{name});

    my $cleared = pmu_clear_query_item($item);
    pmu_remember_query_item($item);

    # Repaint even when the map was already clear. This replaces any stale
    # notification-coloured cells left by an earlier statusbar frame with %w.
    pmu_redraw_statusbar() unless $cleared;
    return 1;
}

sub pmu_signal_window_changed {
    my ($new_window, $old_window) = @_;
    pmu_acknowledge_active_query($new_window, undef);
}

sub pmu_signal_window_item_changed {
    my ($window, $item) = @_;
    pmu_acknowledge_active_query($window, $item);
}

sub pmu_signal_query_created {
    my ($query) = @_;
    return unless pmu_is_query($query);
    pmu_remember_query_item($query);
    pmu_redraw_statusbar();
}


sub pmu_signal_query_destroyed {
    my ($query) = @_;
    delete $matrix_pm_last_incoming{pmu_unread_key($query->{server}, $query->{name})}
        if $query && $query->{server};
    delete $matrix_pm_recent{pmu_unread_key($query->{server}, $query->{name})}
        if $query && $query->{server};
    pmu_clear_query_item($query);
    pmu_forget_query_item($query);
    matrix_query_hostmask_forget($query->{server}, $query->{name})
        if $query && defined &matrix_query_hostmask_forget;
}

sub pmu_signal_nick_changed {
    my ($server, $new_nick, $old_nick, $address) = @_;
    return unless defined $server && defined $new_nick && defined $old_nick;

    matrix_query_hostmask_nick_changed(
        $server, $new_nick, $old_nick, $address,
    ) if defined &matrix_query_hostmask_nick_changed;

    my $changed = 0;
    my $tag_key = lc(pmu_server_tag($server));
    my $last = $last_query_by_tag{$tag_key};
    if ($last) {
        my $old_last_key = pmu_query_key_from_parts(
            pmu_server_tag($server),
            $old_nick,
        );
        my $current_last_key = pmu_query_key_from_parts(
            $last->{tag},
            $last->{nick},
        );
        if ($old_last_key eq $current_last_key) {
            $last->{nick} = $new_nick;
            $changed = 1;
        }
    }

    my $old_key = pmu_unread_key($server, $old_nick);
    my $new_key = pmu_unread_key($server, $new_nick);
    if (exists $matrix_pm_recent{$old_key}) {
        my $old_recent = delete $matrix_pm_recent{$old_key};
        $matrix_pm_recent{$new_key} = $old_recent
            if !exists($matrix_pm_recent{$new_key})
                || $old_recent > $matrix_pm_recent{$new_key};
        $changed = 1;
    }
    if (exists $matrix_pm_last_incoming{$old_key}) {
        my $old_last = delete $matrix_pm_last_incoming{$old_key};
        $matrix_pm_last_incoming{$new_key} = $old_last
            if !exists($matrix_pm_last_incoming{$new_key})
                || $old_last > $matrix_pm_last_incoming{$new_key};
        $changed = 1;
    }
    if (exists $unread{$old_key}) {
        my $entry = delete $unread{$old_key};
        $entry->{nick} = $new_nick;

        if (exists $unread{$new_key}) {
            $unread{$new_key}->{count} += $entry->{count};
            $unread{$new_key}->{first} = $entry->{first}
                if $entry->{first} < $unread{$new_key}->{first};
            $unread{$new_key}->{nick} = $new_nick;
        }
        else {
            $unread{$new_key} = $entry;
        }
        $changed = 1;
    }

    pmu_redraw_statusbar() if $changed;
}

sub pmu_command_pmclear {
    my ($data, $server, $window_item) = @_;
    $data = '' unless defined $data;
    $data =~ s/^\s+|\s+$//g;

    if ($data eq '' || lc($data) eq 'all' || $data eq '*') {
        my $count = scalar keys %unread;
        %unread = ();
        %matrix_pm_last_incoming = ();
        pmu_redraw_statusbar();
        Irssi::print("pm_unread: cleared $count unread conversation(s).");
        return;
    }

    my $removed = 0;

    for my $key (keys %unread) {
        my $tag = $unread{$key}->{tag};
        next unless matrix_tag_casefold($tag, $unread{$key}->{nick})
            eq matrix_tag_casefold($tag, $data);
        delete $unread{$key};
        delete $matrix_pm_last_incoming{$key};
        $removed++;
    }

    pmu_redraw_statusbar();
    Irssi::print(
        $removed
            ? "pm_unread: cleared unread state for $data."
            : "pm_unread: no unread private message from $data."
    );
}

sub pmu_command_pmunread {
    if (!%unread) {
        Irssi::print('pm_unread: no unread private messages.');
        return;
    }

    Irssi::print('pm_unread: unread private messages:');
    for my $entry (sort { $a->{first} <=> $b->{first} } values %unread) {
        my $tag = $entry->{tag} ne '' ? '@' . $entry->{tag} : '';
        Irssi::print('  ' . $entry->{nick} . $tag . ' (' . $entry->{count} . ')');
    }
}

# Diagnostic command: /pmtest [nick]
sub pmu_command_pmtest {
    my ($data, $server, $window_item) = @_;
    $data = '' unless defined $data;
    $data =~ s/^\s+|\s+$//g;
    my $nick = $data ne '' ? $data : 'TestNick';

    my $active_window = Irssi::active_win();
    if (!$server && $active_window) {
        my $active_item = $active_window->{active};
        $server = $active_item->{server}
            if $active_item && $active_item->{server};
        $server = $active_window->{active_server}
            if !$server && $active_window->{active_server};
    }
    $server = eval { Irssi::active_server() } unless $server;
    if (!$server) {
        Irssi::print('pm_unread: select a network before using /pmtest.');
        return;
    }

    my $refnum_hint = defined $active_window && defined $active_window->{refnum}
        ? int($active_window->{refnum})
        : undef;

    pmu_add_unread($server, $nick, $refnum_hint);
    Irssi::print("pm_unread: test alert added for $nick.");
}

sub pmu_install_statusbar_item {
    # PM entries are rendered under each network in the sidebar. Keep the old
    # standalone item detached so reload cannot duplicate the same list.
    Irssi::command('^STATUSBAR REMOVEITEM pm_unread window');

    pmu_redraw_statusbar();
    Irssi::command('^REDRAW');
}

sub pmu_module_unload {
    for my $binding (
        [ 'message private',     'pmu_signal_private_message' ],
        [ 'message own_private', 'pmu_signal_own_private' ],
        [ 'window changed',      'pmu_signal_window_changed' ],
        [ 'window changed automatic', 'pmu_signal_window_changed' ],
        [ 'window item changed', 'pmu_signal_window_item_changed' ],
        [ 'query created',       'pmu_signal_query_created' ],
        [ 'query destroyed',     'pmu_signal_query_destroyed' ],
        [ 'message nick',        'pmu_signal_nick_changed' ],
    ) {
        Irssi::signal_remove($binding->[0], $binding->[1]);
    }
    Irssi::command_unbind('pmclear',  'pmu_command_pmclear');
    Irssi::command_unbind('pmunread', 'pmu_command_pmunread');
    Irssi::command_unbind('pmtest',   'pmu_command_pmtest');

    Irssi::timeout_remove($pmu_gc_timer) if $pmu_gc_timer;
    undef $pmu_gc_timer;
    Irssi::timeout_remove($pmu_final_redraw_timer)
        if defined $pmu_final_redraw_timer;
    undef $pmu_final_redraw_timer;

    for my $pending (values %pmu_focus_timers) {
        Irssi::timeout_remove($pending->{timer}) if $pending->{timer};
    }
    %pmu_focus_timers = ();
    %last_query_by_tag = ();
    %matrix_query_hostmasks = ();

    Irssi::settings_set_bool(
        'autocreate_own_query',
        $pmu_previous_autocreate_own_query ? 1 : 0,
    );

    # The standard window bar is restored centrally during UNLOAD.
}

Irssi::settings_add_int('pm_unread', 'pm_unread_max_nicks', 5);
Irssi::settings_add_int('pm_unread', 'pm_unread_orphan_seconds', 5);
Irssi::settings_add_bool('pm_unread', 'pm_focus_sent_messages', 1);

# Let Irssi create a QUERY when this client sends /MSG nick. The script then
# focuses only that outgoing conversation; receiving a PM never changes window.
$pmu_previous_autocreate_own_query =
    Irssi::settings_get_bool('autocreate_own_query') ? 1 : 0;
Irssi::settings_set_bool('autocreate_own_query', 1);

# Use a defined numeric format value; undef triggers an XS warning on newer Perl/Irssi builds.
Irssi::statusbar_item_register('pm_unread', 0, 'pmu_draw_pm_unread');

Irssi::signal_add('message private',     'pmu_signal_private_message');
Irssi::signal_add('message own_private', 'pmu_signal_own_private');
Irssi::signal_add('window changed',      'pmu_signal_window_changed');
Irssi::signal_add('window changed automatic', 'pmu_signal_window_changed');
Irssi::signal_add('window item changed', 'pmu_signal_window_item_changed');
Irssi::signal_add('query created',       'pmu_signal_query_created');
Irssi::signal_add('query destroyed',     'pmu_signal_query_destroyed');
Irssi::signal_add('message nick', 'pmu_signal_nick_changed');

Irssi::command_bind('pmclear', 'pmu_command_pmclear');
Irssi::command_bind('pmunread', 'pmu_command_pmunread');
Irssi::command_bind('pmtest', 'pmu_command_pmtest');

$pmu_gc_timer = Irssi::timeout_add(5000, 'pmu_prune_stale_entries', 0);
pmu_install_statusbar_item();

}

# ============================================================================
# prv_guard.pl
# ============================================================================
{
# -----------------------------------------------------------------------------
# SETTINGS: ordinary private messages
# -----------------------------------------------------------------------------

Irssi::settings_add_bool('prv_guard', 'prv_guard_enabled', 1);

# Four distinct new senders within one second trigger the MASS PM block.
Irssi::settings_add_int ('prv_guard', 'prv_guard_mass_limit', 4);
Irssi::settings_add_time('prv_guard', 'prv_guard_mass_window', '1sec');
Irssi::settings_add_time('prv_guard', 'prv_guard_mass_block_time', '60sec');

# Close only QUERY windows that did not exist before the detected flood and
# were created by its delivered prefix. Existing and explicitly trusted
# conversations are never closed. The short delay lets Irssi finish creating
# QUERY objects for the few lines delivered before the MASS threshold fired.
Irssi::settings_add_bool('prv_guard', 'prv_guard_auto_close_flood_queries', 1);
Irssi::settings_add_time('prv_guard', 'prv_guard_auto_close_delay', '200ms');

# Hard cap for attacker identities retained by MASS PRV/CTCP accounting.
Irssi::settings_add_int ('prv_guard', 'prv_guard_identity_cap', 256);

# One nickname sending multiple lines.
Irssi::settings_add_int ('prv_guard', 'prv_guard_sender_limit', 8);
Irssi::settings_add_time('prv_guard', 'prv_guard_sender_window', '3sec');
Irssi::settings_add_time('prv_guard', 'prv_guard_sender_block_time', '60sec');

# Protect against slow query creation below the MASS threshold.
Irssi::settings_add_int ('prv_guard', 'prv_guard_max_queries', 40);

# Trusted masks, e.g.:
# /set prv_guard_allow_masks BR!*@* mh!*@trusted.host
Irssi::settings_add_str ('prv_guard', 'prv_guard_allow_masks', '');

# Emergency cleanup with /prvguard purge.
Irssi::settings_add_int ('prv_guard', 'prv_guard_purge_batch', 100);
Irssi::settings_add_time('prv_guard', 'prv_guard_purge_delay', '50ms');

# -----------------------------------------------------------------------------
# SETTINGS: CTCP
# -----------------------------------------------------------------------------

# Modes:
#   off    - no separate CTCP filter; ordinary MASS PM protection remains active
#   limit  - CTCP is allowed within the limits below
#   strict - default: CTCP from new nicknames without a query is blocked immediately;
#            CTCP in existing conversations is rate-limited
#   block  - blocks all CTCP except trusted masks
#
# Private /ME (CTCP ACTION) is not treated as technical CTCP.
# It passes through ordinary MASS PM protection.
Irssi::settings_add_str ('prv_guard', 'prv_guard_ctcp_mode', 'strict');

# Limit technical CTCP from one sender.
# The first three within ten seconds pass; the fourth triggers a sender block.
Irssi::settings_add_int ('prv_guard', 'prv_guard_ctcp_sender_limit', 3);
Irssi::settings_add_time('prv_guard', 'prv_guard_ctcp_sender_window', '10sec');
Irssi::settings_add_time('prv_guard', 'prv_guard_ctcp_sender_block_time', '60sec');

# Four distinct CTCP senders within one second trigger a global CTCP block.
Irssi::settings_add_int ('prv_guard', 'prv_guard_ctcp_mass_limit', 4);
Irssi::settings_add_time('prv_guard', 'prv_guard_ctcp_mass_window', '1sec');
Irssi::settings_add_time('prv_guard', 'prv_guard_ctcp_mass_block_time', '60sec');

# DCC is sent as CTCP DCC. When ON, new DCC without an existing query
# is also blocked in limit mode.
Irssi::settings_add_bool('prv_guard', 'prv_guard_ctcp_block_new_dcc', 1);

# PING and VERSION are harmless diagnostic CTCP requests. In strict mode they
# may pass even without an existing QUERY, but they are still covered by the
# per-sender and MASS CTCP limits. Replies to requests explicitly sent by this
# client are allowed only from the requested nick and only for a short window.
Irssi::settings_add_bool('prv_guard', 'prv_guard_ctcp_allow_ping_version', 1);
Irssi::settings_add_time('prv_guard', 'prv_guard_ctcp_reply_window', '15sec');

# -----------------------------------------------------------------------------
# STATE: ordinary PMs
# -----------------------------------------------------------------------------

my %burst_events;          # tag => [{time,nick,address,sender}]
my %mass_until;            # tag => timestamp
my %mass_blocked;          # tag => number of blocked messages
my %mass_unique;           # tag => { sender => 1 }
my %mass_samples;          # tag => [nick!address, ...]
my %mass_timer;            # tag => timeout id
my %mass_unique_overflow;  # tag => identities beyond the configured cap
my %flood_query_cleanup;   # tag => { nicks => { lc(nick) => nick }, reason => str }
my %flood_query_cleanup_timer; # tag => timeout id

my %sender_events;         # tag\0sender => [timestamps]
my %sender_started_new;    # tag\0sender => 1, jezeli seria zaczela sie bez QUERY
my %sender_block_until;    # tag\0sender => timestamp
my %sender_notice_until;   # tag\0sender => timestamp

# A conversation is trusted only after the user sends a private message
# while this script instance is running. Query existence alone does not grant trust.
my %trusted_queries;       # lc(tag)\0lc(nick) => 1

# -----------------------------------------------------------------------------
# STATE: CTCP
# -----------------------------------------------------------------------------

my %ctcp_events;               # tag => [{time,nick,address,sender,command,blocked}]
my %ctcp_mass_until;           # tag => timestamp
my %ctcp_mass_blocked;         # tag => number of CTCP packets blocked during MASS
my %ctcp_mass_unique;          # tag => { sender => 1 }
my %ctcp_mass_samples;         # tag => [nick!address/COMMAND, ...]
my %ctcp_mass_timer;           # tag => timeout id
my %ctcp_mass_unique_overflow; # tag => identities beyond the configured cap

my %ctcp_sender_events;        # tag\0sender => [timestamps]
my %ctcp_sender_block_until;   # tag\0sender => timestamp
my %ctcp_sender_notice_until;  # tag\0sender => timestamp
my %ctcp_expected_replies;     # lc(tag)\0lc(nick)\0COMMAND => expiry

# -----------------------------------------------------------------------------
# STATE: purge
# -----------------------------------------------------------------------------

my $purge_timer;
my $purge_state;
my $guard_gc_timer;

# -----------------------------------------------------------------------------
# HELPERS
# -----------------------------------------------------------------------------

sub guard_server_tag {
    my ($server) = @_;
    return 'unknown' unless $server;
    return $server->{tag} || $server->{chatnet} || 'unknown';
}

sub guard_sender_id {
    my ($nick, $address, $server) = @_;
    $nick    = '' unless defined $nick;
    $address = '' unless defined $address;

    # user@host is normally stable across IRC nick changes. Fall back to the nick
    # only when the server did not provide an address.
    return length($address)
        ? 'addr:' . lc($address)
        : 'nick:' . matrix_irc_casefold($server, $nick);
}


sub guard_merge_time_array {
    my ($target, $source) = @_;
    return unless ref($source) eq 'ARRAY';
    $target = [] unless ref($target) eq 'ARRAY';
    push @$target, @$source;
    my %seen;
    @$target = sort { $a <=> $b } grep { defined($_) && !$seen{"$_"}++ } @$target;
    return $target;
}

sub guard_migrate_sender_state {
    my ($server, $new_nick, $old_nick, $address) = @_;
    return unless $server;
    return if defined($address) && length($address);
    return unless defined($new_nick) && length($new_nick)
        && defined($old_nick) && length($old_nick);

    my $tag = guard_server_tag($server);
    my $old_state = guard_state_key($tag, guard_sender_id($old_nick, '', $server));
    my $new_state = guard_state_key($tag, guard_sender_id($new_nick, '', $server));
    return if $old_state eq $new_state;

    if (exists $sender_events{$old_state}) {
        $sender_events{$new_state} = guard_merge_time_array(
            $sender_events{$new_state}, delete $sender_events{$old_state}
        );
    }
    if (delete $sender_started_new{$old_state}) {
        $sender_started_new{$new_state} = 1;
    }
    if (exists $ctcp_sender_events{$old_state}) {
        $ctcp_sender_events{$new_state} = guard_merge_time_array(
            $ctcp_sender_events{$new_state},
            delete $ctcp_sender_events{$old_state}
        );
    }

    for my $hash (
        \%sender_block_until, \%sender_notice_until,
        \%ctcp_sender_block_until, \%ctcp_sender_notice_until,
    ) {
        next unless exists $hash->{$old_state};
        my $value = delete $hash->{$old_state};
        $hash->{$new_state} = $value
            if !exists($hash->{$new_state}) || $value > $hash->{$new_state};
    }
}

sub guard_identity_cap {
    my $cap = Irssi::settings_get_int('prv_guard_identity_cap');
    $cap = 256 if !defined($cap) || $cap < 16;
    $cap = 4096 if $cap > 4096;
    return $cap;
}

sub guard_capped_identity_add {
    my ($set, $identity, $overflow_ref) = @_;
    return 0 unless ref($set) eq 'HASH' && defined($identity) && length($identity);
    return 1 if exists $set->{$identity};

    if (scalar(keys %$set) >= guard_identity_cap()) {
        $$overflow_ref++ if ref($overflow_ref) eq 'SCALAR';
        return 0;
    }

    $set->{$identity} = 1;
    return 1;
}

sub guard_state_key {
    my ($tag, $sender) = @_;
    return lc($tag) . "\0" . $sender;
}

sub guard_query_key {
    my ($server, $nick) = @_;
    my $tag = guard_server_tag($server);
    $nick = '' unless defined $nick;
    return lc($tag) . "\0" . matrix_irc_casefold($server, $nick);
}

sub guard_mark_query_trusted {
    my ($server, $nick) = @_;
    return unless $server && defined($nick) && length($nick);
    $trusted_queries{ guard_query_key($server, $nick) } = 1;
}

sub guard_query_is_trusted {
    my ($server, $nick) = @_;
    return 0 unless $server && defined($nick) && length($nick);
    return $trusted_queries{ guard_query_key($server, $nick) } ? 1 : 0;
}

sub guard_migrate_query_trust {
    my ($server, $new_nick, $old_nick) = @_;
    return 0 unless $server
        && defined($new_nick) && length($new_nick)
        && defined($old_nick) && length($old_nick);

    my $old_key = guard_query_key($server, $old_nick);
    return 0 unless delete $trusted_queries{$old_key};
    $trusted_queries{ guard_query_key($server, $new_nick) } = 1;
    return 1;
}

sub guard_revoke_query_trust {
    my ($server, $nick) = @_;
    return 0 unless $server && defined($nick) && length($nick);
    return delete($trusted_queries{ guard_query_key($server, $nick) }) ? 1 : 0;
}

sub guard_server_for_tag_key {
    my ($tag_key) = @_;
    return undef unless defined($tag_key) && length($tag_key);
    for my $server (Irssi::servers()) {
        next unless $server;
        return $server if lc(guard_server_tag($server)) eq lc($tag_key);
    }
    return undef;
}

sub guard_prune_trusted_queries {
    for my $key (keys %trusted_queries) {
        my ($tag_key, $nick) = split /\0/, $key, 2;
        my $server = guard_server_for_tag_key($tag_key);
        if (!$server || !$server->{connected}
            || !defined($nick) || !length($nick)
            || !eval { $server->query_find($nick) }) {
            delete $trusted_queries{$key};
        }
    }
}

sub guard_setting_seconds {
    my ($name) = @_;
    my $milliseconds = Irssi::settings_get_time($name);
    return $milliseconds > 0 ? $milliseconds / 1000 : 0;
}

sub guard_guard_print {
    my ($text, $network_context) = @_;
    $text = '' unless defined $text;
    $text =~ s/[\x00-\x1f\x7f-\x9f]//g;
    $text =~ s/%/%%/g;
    Irssi::print('%R[PRV-GUARD]%n ' . $text, MSGLEVEL_CLIENTNOTICE);

    if (defined($network_context) && defined &netbar_mark_status_attention) {
        netbar_mark_status_attention($network_context);
    }
}

sub guard_sender_allowed {
    my ($server, $nick, $address) = @_;

    my $masks = Irssi::settings_get_str('prv_guard_allow_masks');
    return 0 unless defined $masks && $masks =~ /\S/;

    return $server->masks_match(
        $masks,
        $nick || '',
        $address || ''
    ) ? 1 : 0;
}

sub guard_is_private_target {
    my ($server, $target) = @_;

    return 0 unless $server;
    return 0 unless defined $target && length $target;
    return 0 if $server->ischannel($target);

    # NOTICE/PRIVMSG to a channel status target, e.g. @#channel.
    my $stripped = $target;
    $stripped =~ s/^[~&@%+]+//;
    return 0 if $stripped ne $target && $server->ischannel($stripped);

    return 1;
}

sub guard_parse_event_data {
    my ($data) = @_;
    return unless defined $data;

    # Normally: "target :text". Also accept input without the colon.
    if ($data =~ /^(\S+)\s+:(.*)$/s) {
        return ($1, $2);
    }
    if ($data =~ /^(\S+)\s+(.*)$/s) {
        return ($1, $2);
    }
    return;
}

sub guard_ctcp_info {
    my ($message) = @_;
    return unless defined $message;

    # Embedded SOH in ordinary text is not CTCP. Exactly one complete frame must
    # occupy the whole IRC trailing parameter.
    return unless substr($message, 0, 1) eq "\001";

    my $delimiters = () = $message =~ /\001/g;
    return ('MALFORMED', 0)
        unless $delimiters == 2 && $message =~ /\A\001([^\001]*)\001\z/s;

    my $payload = $1;
    return ('MALFORMED', 0) unless length($payload);

    my ($command) = $payload =~ /\A([^\s]+)/;
    $command = defined($command) ? uc($command) : 'MALFORMED';
    my $pure_action = $payload =~ /\AACTION(?:\s[^\001]*)?\z/s ? 1 : 0;

    return ($command, $pure_action);
}

sub guard_query_count_for_server {
    my ($server) = @_;
    my @queries = $server->queries();
    return scalar @queries;
}

sub guard_destroy_query_safe {
    my ($query) = @_;
    return 0 unless $query;

    # Remove the unread-PM indicator before destroying an attack QUERY. This is
    # immediate and does not depend on Irssi delivering query-destroyed signals
    # in a particular order during a large burst.
    pmu_clear_query_item($query) if defined &pmu_clear_query_item;
    pmu_forget_query_item($query) if defined &pmu_forget_query_item;

    my $ok = eval {
        $query->destroy();
        1;
    };
    return $ok ? 1 : 0;
}


# Schedule deletion only for QUERY windows which did not exist before the
# delivered part of a detected flood. A conversation becomes protected from
# cleanup immediately after the user sends a private message to that nick.
sub guard_schedule_flood_query_cleanup {
    my ($server, $nicks, $reason) = @_;
    return unless Irssi::settings_get_bool(
        'prv_guard_auto_close_flood_queries'
    );
    return unless $server && ref($nicks) eq 'ARRAY' && @$nicks;

    my $tag = guard_server_tag($server);
    my $pending = ($flood_query_cleanup{$tag} ||= {
        nicks  => {},
        reason => defined($reason) ? $reason : 'PM flood',
    });

    for my $nick (@$nicks) {
        next unless defined($nick) && length($nick);
        next if eval { $server->ischannel($nick) };
        $pending->{nicks}->{matrix_irc_casefold($server, $nick)} = $nick;
    }
    return unless keys %{ $pending->{nicks} };

    # Do not continually postpone cleanup while the attack continues. New
    # candidates join the already scheduled batch and share its fixed deadline.
    return if $flood_query_cleanup_timer{$tag};

    my $delay = Irssi::settings_get_time('prv_guard_auto_close_delay');
    $delay = 200 if !defined($delay) || $delay < 50;
    $delay = 5000 if $delay > 5000;

    $flood_query_cleanup_timer{$tag} = Irssi::timeout_add_once(
        $delay,
        \&guard_finish_flood_query_cleanup,
        $tag,
    );
}

sub guard_finish_flood_query_cleanup {
    my ($tag) = @_;
    delete $flood_query_cleanup_timer{$tag};
    my $pending = delete $flood_query_cleanup{$tag};
    return unless $pending && ref($pending->{nicks}) eq 'HASH';

    my $server = guard_server_for_tag_key($tag);
    return unless $server && $server->{connected};

    my ($closed, $cleared) = (0, 0);
    for my $nick (values %{ $pending->{nicks} }) {
        next unless defined($nick) && length($nick);

        # A reply sent by the user between detection and cleanup makes the
        # conversation trusted and therefore immune to automatic deletion.
        next if guard_query_is_trusted($server, $nick);

        my $query = eval { $server->query_find($nick) };
        if ($query) {
            $closed++ if guard_destroy_query_safe($query);
        }
        elsif (defined &pmu_clear_query_name) {
            $cleared++ if pmu_clear_query_name($server, $nick);
        }
    }

    if ($closed || $cleared) {
        my $message = "automatic cleanup after "
            . ($pending->{reason} || 'PM flood')
            . ": closed $closed new untrusted queries";
        $message .= "; cleared $cleared remaining PM alerts" if $cleared;
        $message .= ". Existing and trusted conversations were preserved.";
        guard_guard_print($message, $server);
    }
}

sub guard_new_attack_query_nicks {
    my ($events, $server) = @_;
    return () unless ref($events) eq 'ARRAY';

    my %nicks;
    for my $event (@$events) {
        next unless ref($event) eq 'HASH';
        next if $event->{had_query};
        my $nick = $event->{nick} // '';
        next unless length($nick);
        $nicks{matrix_irc_casefold($server, $nick)} = $nick;
    }
    return values %nicks;
}

# -----------------------------------------------------------------------------
# MASS PRV
# -----------------------------------------------------------------------------

sub guard_prune_burst_events {
    my ($tag, $now) = @_;

    my $window = guard_setting_seconds('prv_guard_mass_window');
    $window = 1 if $window <= 0;

    my $events = ($burst_events{$tag} ||= []);
    @$events = grep { $_->{time} >= ($now - $window) } @$events;
    return $events;
}

sub guard_remember_mass_block {
    my ($tag, $nick, $address) = @_;
    my $server = Irssi::server_find_tag($tag);

    my $sender = guard_sender_id($nick, $address, $server);
    $mass_blocked{$tag}++;

    my $overflow = $mass_unique_overflow{$tag} || 0;
    guard_capped_identity_add(
        ($mass_unique{$tag} ||= {}),
        $sender,
        \$overflow,
    );
    $mass_unique_overflow{$tag} = $overflow;

    my $samples = ($mass_samples{$tag} ||= []);
    if (@$samples < 5) {
        my $mask = ($nick || '?') . '!' . ($address || '?');
        push @$samples, $mask unless grep { $_ eq $mask } @$samples;
    }
}


# MASS PRV blocks delivery of untrusted private traffic. It may additionally
# close only new untrusted QUERY objects created by the delivered prefix of the
# detected attack. QUERY windows which existed before the burst are preserved.

sub guard_schedule_mass_finish {
    my ($tag, $milliseconds) = @_;
    $milliseconds = 100 if $milliseconds < 100;

    if (my $old = delete $mass_timer{$tag}) {
        Irssi::timeout_remove($old);
    }

    $mass_timer{$tag} = Irssi::timeout_add_once(
        $milliseconds,
        \&guard_finish_mass_lock,
        $tag
    );
}

sub guard_finish_mass_lock {
    my ($tag) = @_;
    return unless exists $mass_until{$tag};

    my $now = time();
    if ($mass_until{$tag} > $now) {
        guard_schedule_mass_finish(
            $tag,
            int(($mass_until{$tag} - $now) * 1000) + 100
        );
        return;
    }

    my $blocked = $mass_blocked{$tag} || 0;
    my $unique = scalar keys %{ $mass_unique{$tag} || {} };
    my $overflow = $mass_unique_overflow{$tag} || 0;
    my $samples = join(', ', @{ $mass_samples{$tag} || [] });

    my $summary =
        "MASS PM block on $tag ended; blocked $blocked " .
        "messages from at least $unique senders";
    $summary .= " (tracking limit reached; $overflow additional events)"
        if $overflow;
    $summary .= "; examples: $samples" if length $samples;
    guard_guard_print($summary . '.', $tag);

    delete $mass_until{$tag};
    delete $mass_blocked{$tag};
    delete $mass_unique{$tag};
    delete $mass_unique_overflow{$tag};
    delete $mass_samples{$tag};
    delete $mass_timer{$tag};
    delete $burst_events{$tag};
}

sub guard_activate_mass_lock {
    my ($server, $now, $nick, $address, $reason, $events) = @_;

    my $tag = guard_server_tag($server);
    my $block_time = guard_setting_seconds('prv_guard_mass_block_time');
    $block_time = 60 if $block_time <= 0;

    guard_remember_mass_block($tag, $nick, $address);

    # Absolute deadline: more traffic is counted but cannot perpetually extend
    # the lock. Cleanup is scheduled only once for the burst that activated it.
    return if ($mass_until{$tag} || 0) > $now;

    my @attack_queries = guard_new_attack_query_nicks($events, $server);
    guard_schedule_flood_query_cleanup(
        $server,
        \@attack_queries,
        'detected MASS PM flood',
    ) if @attack_queries;

    $mass_until{$tag} = $now + $block_time;
    guard_schedule_mass_finish($tag, int($block_time * 1000) + 100);

    my $cleanup_text = Irssi::settings_get_bool(
        'prv_guard_auto_close_flood_queries'
    )
        ? ' New untrusted queries created by this flood are closed automatically;'
        : ' Existing query windows were preserved;';

    guard_guard_print(
        "detected $reason on $tag." . $cleanup_text .
        " new untrusted private traffic is blocked for " .
        int($block_time) . " s from activation. Existing and trusted conversations and allowlisted senders remain available.",
        $server,
    );
}

sub guard_block_single_sender {
    my ($server, $now, $nick, $address, $query, $state) = @_;

    my $tag = guard_server_tag($server);
    my $block_time = guard_setting_seconds('prv_guard_sender_block_time');
    $block_time = 60 if $block_time <= 0;

    $sender_block_until{$state} = $now + $block_time;

    if ($sender_started_new{$state}) {
        guard_schedule_flood_query_cleanup(
            $server,
            [ $nick ],
            'single-sender PM flood',
        );
    }

    if (($sender_notice_until{$state} || 0) <= $now) {
        my $mask = ($nick || '?') . '!' . ($address || '?');
        guard_guard_print(
            "rapid PM flood from $mask on $tag: sender blocked for " .
            int($block_time) . " s; new queries created by this flood are closed automatically; existing or trusted queries are preserved.",
            $server,
        );
        $sender_notice_until{$state} = $now + $block_time;
    }
}

sub guard_sig_own_private {
    my ($server, $message, $target, $original_target) = @_;
    return unless $server && defined($target) && length($target);

    for my $nick (split /,/, $target) {
        $nick =~ s/^\s+|\s+$//g;
        next if $nick eq '';
        next if eval { $server->ischannel($nick) };
        guard_mark_query_trusted($server, $nick);
    }
}

sub guard_sig_query_destroyed {
    my ($query) = @_;
    return unless $query && $query->{server};
    guard_revoke_query_trust($query->{server}, $query->{name} || '');
}

sub guard_sig_nick_changed {
    my ($server, $new_nick, $old_nick, $address) = @_;
    guard_migrate_query_trust($server, $new_nick, $old_nick);
    guard_migrate_sender_state($server, $new_nick, $old_nick, $address);
}

sub guard_sig_message_quit {
    my ($server, $nick, $address, $reason, $channels) = @_;
    guard_revoke_query_trust($server, $nick);
}

sub guard_clear_server_state {
    my ($server) = @_;
    return unless $server;

    my $tag = guard_server_tag($server);
    my $prefix = lc($tag) . "\0";

    for my $timer_hash (\%mass_timer, \%ctcp_mass_timer, \%flood_query_cleanup_timer) {
        for my $key (keys %$timer_hash) {
            next unless lc($key) eq lc($tag);
            my $timer = delete $timer_hash->{$key};
            Irssi::timeout_remove($timer) if $timer;
        }
    }

    for my $hash (\%burst_events, \%mass_until, \%mass_blocked,
                  \%mass_unique, \%mass_unique_overflow, \%mass_samples,
                  \%flood_query_cleanup,
                  \%ctcp_events, \%ctcp_mass_until, \%ctcp_mass_blocked,
                  \%ctcp_mass_unique, \%ctcp_mass_unique_overflow, \%ctcp_mass_samples) {
        for my $key (keys %$hash) {
            delete $hash->{$key} if lc($key) eq lc($tag);
        }
    }

    for my $hash (\%sender_events, \%sender_started_new,
                  \%sender_block_until, \%sender_notice_until,
                  \%ctcp_sender_events, \%ctcp_sender_block_until,
                  \%ctcp_sender_notice_until, \%ctcp_expected_replies,
                  \%trusted_queries) {
        delete $hash->{$_} for grep { index($_, $prefix) == 0 } keys %$hash;
    }
}

sub guard_sig_server_disconnected {
    my ($server) = @_;
    guard_clear_server_state($server);
}

# -----------------------------------------------------------------------------
# CTCP
# -----------------------------------------------------------------------------

sub guard_ctcp_command_is_safe {
    my ($command) = @_;
    $command = uc(defined($command) ? $command : '');
    return 0 unless Irssi::settings_get_bool('prv_guard_ctcp_allow_ping_version');
    return $command eq 'PING' || $command eq 'VERSION' ? 1 : 0;
}

sub guard_ctcp_reply_key {
    my ($server, $nick, $command) = @_;
    return '' unless $server && defined($nick) && length($nick);
    $command = uc(defined($command) ? $command : '');
    return '' unless length($command);

    return lc(guard_server_tag($server)) . "\0"
        . matrix_irc_casefold($server, $nick) . "\0" . $command;
}

sub guard_ctcp_mark_expected_reply {
    my ($server, $nick, $command) = @_;
    return unless guard_ctcp_command_is_safe($command);
    return unless $server && $server->{connected};
    return unless defined($nick) && length($nick);
    return if eval { $server->ischannel($nick) };

    my $key = guard_ctcp_reply_key($server, $nick, $command);
    return unless length($key);

    my $window = guard_setting_seconds('prv_guard_ctcp_reply_window');
    $window = 15 if $window <= 0;
    $ctcp_expected_replies{$key} = time() + $window;
}

sub guard_ctcp_consume_expected_reply {
    my ($server, $nick, $command) = @_;
    return 0 unless guard_ctcp_command_is_safe($command);

    my $key = guard_ctcp_reply_key($server, $nick, $command);
    return 0 unless length($key);

    my $expires = delete $ctcp_expected_replies{$key};
    return 0 unless defined $expires;
    return $expires >= time() ? 1 : 0;
}

sub guard_ctcp_track_targets {
    my ($server, $targets, $command) = @_;
    return unless $server && defined($targets) && length($targets);
    return unless guard_ctcp_command_is_safe($command);

    for my $nick (split /,/, $targets) {
        $nick =~ s/^\s+|\s+$//g;
        next if $nick eq '' || $nick =~ /^-/;
        guard_ctcp_mark_expected_reply($server, $nick, $command);
    }
}

sub guard_cmd_track_ping {
    my ($data, $server, $witem) = @_;
    $data = '' unless defined $data;
    $data =~ s/^\s+|\s+$//g;

    my ($targets) = split /\s+/, $data, 2;
    if ((!defined($targets) || $targets eq '') && $witem
        && defined($witem->{name}) && $witem->{server}) {
        $server = $witem->{server};
        $targets = $witem->{name};
    }
    guard_ctcp_track_targets($server, $targets, 'PING');
}

sub guard_cmd_track_ver {
    my ($data, $server, $witem) = @_;
    $data = '' unless defined $data;
    $data =~ s/^\s+|\s+$//g;

    my ($targets) = split /\s+/, $data, 2;
    if ((!defined($targets) || $targets eq '') && $witem
        && defined($witem->{name}) && $witem->{server}) {
        $server = $witem->{server};
        $targets = $witem->{name};
    }
    guard_ctcp_track_targets($server, $targets, 'VERSION');
}

sub guard_cmd_track_ctcp {
    my ($data, $server, $witem) = @_;
    $data = '' unless defined $data;
    $data =~ s/^\s+|\s+$//g;

    my ($targets, $command) = split /\s+/, $data, 3;
    return unless defined($targets) && defined($command);
    guard_ctcp_track_targets($server, $targets, uc($command));
}

sub guard_ctcp_mode {
    my $mode = lc(Irssi::settings_get_str('prv_guard_ctcp_mode') || 'strict');
    return $mode if $mode =~ /^(?:off|limit|strict|block)$/;
    return 'strict';
}

sub guard_prune_ctcp_events {
    my ($tag, $now) = @_;

    my $window = guard_setting_seconds('prv_guard_ctcp_mass_window');
    $window = 1 if $window <= 0;

    my $events = ($ctcp_events{$tag} ||= []);
    @$events = grep { $_->{time} >= ($now - $window) } @$events;
    return $events;
}

sub guard_remember_ctcp_mass_block {
    my ($tag, $nick, $address, $command) = @_;
    my $server = Irssi::server_find_tag($tag);

    my $sender = guard_sender_id($nick, $address, $server);
    $ctcp_mass_blocked{$tag}++;

    my $overflow = $ctcp_mass_unique_overflow{$tag} || 0;
    guard_capped_identity_add(
        ($ctcp_mass_unique{$tag} ||= {}),
        $sender,
        \$overflow,
    );
    $ctcp_mass_unique_overflow{$tag} = $overflow;

    my $samples = ($ctcp_mass_samples{$tag} ||= []);
    if (@$samples < 5) {
        my $sample =
            ($nick || '?') . '!' . ($address || '?') . '/' . ($command || '?');
        push @$samples, $sample unless grep { $_ eq $sample } @$samples;
    }
}

sub guard_schedule_ctcp_mass_finish {
    my ($tag, $milliseconds) = @_;
    $milliseconds = 100 if $milliseconds < 100;

    if (my $old = delete $ctcp_mass_timer{$tag}) {
        Irssi::timeout_remove($old);
    }

    $ctcp_mass_timer{$tag} = Irssi::timeout_add_once(
        $milliseconds,
        \&guard_finish_ctcp_mass_lock,
        $tag
    );
}

sub guard_finish_ctcp_mass_lock {
    my ($tag) = @_;
    return unless exists $ctcp_mass_until{$tag};

    my $now = time();
    if ($ctcp_mass_until{$tag} > $now) {
        guard_schedule_ctcp_mass_finish(
            $tag,
            int(($ctcp_mass_until{$tag} - $now) * 1000) + 100
        );
        return;
    }

    my $blocked = $ctcp_mass_blocked{$tag} || 0;
    my $unique = scalar keys %{ $ctcp_mass_unique{$tag} || {} };
    my $overflow = $ctcp_mass_unique_overflow{$tag} || 0;
    my $samples = join(', ', @{ $ctcp_mass_samples{$tag} || [] });

    my $summary =
        "CTCP block on $tag ended; blocked $blocked packets of " .
        "CTCP from at least $unique senders";
    $summary .= " (tracking limit reached; $overflow additional events)"
        if $overflow;
    $summary .= "; examples: $samples" if length $samples;
    guard_guard_print($summary . '.', $tag);

    delete $ctcp_mass_until{$tag};
    delete $ctcp_mass_blocked{$tag};
    delete $ctcp_mass_unique{$tag};
    delete $ctcp_mass_unique_overflow{$tag};
    delete $ctcp_mass_samples{$tag};
    delete $ctcp_mass_timer{$tag};
    delete $ctcp_events{$tag};
}

sub guard_activate_ctcp_mass_lock {
    my ($server, $now, $nick, $address, $command, $events, $prior_blocked) = @_;

    my $tag = guard_server_tag($server);
    my $block_time = guard_setting_seconds('prv_guard_ctcp_mass_block_time');
    $block_time = 60 if $block_time <= 0;

    if (($ctcp_mass_until{$tag} || 0) > $now) {
        guard_remember_ctcp_mass_block($tag, $nick, $address, $command);
        return;
    }

    if ($prior_blocked) {
        for my $event (@$events) {
            guard_remember_ctcp_mass_block(
                $tag,
                $event->{nick},
                $event->{address},
                $event->{command}
            );
            $ctcp_mass_blocked{$tag} += ($event->{count} || 1) - 1;
        }
    } else {
        guard_remember_ctcp_mass_block($tag, $nick, $address, $command);
    }

    delete $ctcp_events{$tag};
    $ctcp_mass_until{$tag} = $now + $block_time;
    guard_schedule_ctcp_mass_finish($tag, int($block_time * 1000) + 100);

    my $limit = Irssi::settings_get_int('prv_guard_ctcp_mass_limit');
    my $window = guard_setting_seconds('prv_guard_ctcp_mass_window');
    guard_guard_print(
        "MASS CTCP detected: $limit distinct senders/$window s on $tag. " .
        "Technical CTCP and CTCP replies are blocked for " .
        int($block_time) . " s from activation; further attempts do not extend the block.",
        $server,
    );
}

sub guard_block_ctcp_sender {
    my ($server, $now, $nick, $address, $command, $state) = @_;

    my $tag = guard_server_tag($server);
    my $block_time = guard_setting_seconds('prv_guard_ctcp_sender_block_time');
    $block_time = 60 if $block_time <= 0;

    $ctcp_sender_block_until{$state} = $now + $block_time;

    if (($ctcp_sender_notice_until{$state} || 0) <= $now) {
        my $mask = ($nick || '?') . '!' . ($address || '?');
        guard_guard_print(
            "CTCP $command flood from $mask on $tag: sender blocked for " .
            int($block_time) . ' s.',
            $server,
        );
        $ctcp_sender_notice_until{$state} = $now + $block_time;
    }
}

# Return 1 when CTCP should be blocked.
sub guard_should_block_ctcp {
    my ($server, $nick, $address, $message, $kind) = @_;

    my ($command, $pure_action) = guard_ctcp_info($message);
    return 0 unless defined $command;
    return 0 if $kind eq 'PRIVMSG' && $pure_action;
    return 0 if guard_sender_allowed($server, $nick, $address);

    if ($kind eq 'NOTICE'
        && guard_ctcp_consume_expected_reply($server, $nick, $command)) {
        return 0;
    }

    my $mode = guard_ctcp_mode();
    return 0 if $mode eq 'off';

    my $now = time();
    my $tag = guard_server_tag($server);
    my $sender = guard_sender_id($nick, $address, $server);
    my $state = guard_state_key($tag, $sender);
    my $trusted = guard_query_is_trusted($server, $nick);

    if (($ctcp_mass_until{$tag} || 0) > $now) {
        guard_remember_ctcp_mass_block($tag, $nick, $address, $command);
        return 1;
    }

    my $mass_limit = Irssi::settings_get_int('prv_guard_ctcp_mass_limit');
    my $events = $mass_limit > 0 ? guard_prune_ctcp_events($tag, $now) : [];
    # Repeated packets from one identity cannot grow the mass-detection list.
    # Keep count for the existing report, but only one last-seen timestamp.
    my ($prior) = grep { $_->{sender} eq $sender } @$events;
    @$events = grep { $_->{sender} ne $sender } @$events;
    push @$events, {
        time    => $now,
        nick    => $nick || '',
        address => $address || '',
        sender  => $sender,
        command => $command,
        count   => ($prior ? ($prior->{count} || 1) : 0) + 1,
    };

    if ($mass_limit > 0 && @$events >= $mass_limit) {
        my $prior_blocked = ($mode eq 'strict' || $mode eq 'block') ? 1 : 0;
        guard_activate_ctcp_mass_lock(
            $server, $now, $nick, $address, $command, $events, $prior_blocked
        );
        return 1;
    }

    return 1 if $mode eq 'block';
    return 1 if ($ctcp_sender_block_until{$state} || 0) > $now;

    # Trust must be explicit (own message sent), not inferred from QUERY existence.
    if ($command eq 'DCC'
        && !$trusted
        && Irssi::settings_get_bool('prv_guard_ctcp_block_new_dcc')) {
        return 1;
    }

    return 1 if $mode eq 'strict' && !$trusted
        && !guard_ctcp_command_is_safe($command);

    my $window = guard_setting_seconds('prv_guard_ctcp_sender_window');
    $window = 10 if $window <= 0;

    my $limit = Irssi::settings_get_int('prv_guard_ctcp_sender_limit');
    my $times = ($ctcp_sender_events{$state} ||= []);
    @$times = grep { $_ >= ($now - $window) } @$times;
    push @$times, $now;

    if ($limit > 0 && @$times > $limit) {
        guard_block_ctcp_sender($server, $now, $nick, $address, $command, $state);
        return 1;
    }

    return 0;
}

sub guard_should_block_regular_private {
    my ($server, $nick, $address, $kind) = @_;
    return 0 if guard_sender_allowed($server, $nick, $address);

    my $now = time();
    my $tag = guard_server_tag($server);
    my $sender = guard_sender_id($nick, $address, $server);
    my $state = guard_state_key($tag, $sender);
    my $query = eval { $server->query_find($nick || '') };
    my $trusted = guard_query_is_trusted($server, $nick);

    if (($mass_until{$tag} || 0) > $now && !$trusted) {
        guard_remember_mass_block($tag, $nick, $address);
        return 1;
    }

    return 1 if ($sender_block_until{$state} || 0) > $now;

    my $sender_window = guard_setting_seconds('prv_guard_sender_window');
    $sender_window = 3 if $sender_window <= 0;
    my $sender_limit = Irssi::settings_get_int('prv_guard_sender_limit');

    my $times = ($sender_events{$state} ||= []);
    @$times = grep { $_ >= ($now - $sender_window) } @$times;
    $sender_started_new{$state} = $query ? 0 : 1 unless @$times;
    push @$times, $now;

    if ($sender_limit > 0 && @$times >= $sender_limit) {
        guard_block_single_sender($server, $now, $nick, $address, $query, $state);
        return 1;
    }

    # All untrusted private traffic participates in MASS accounting.
    if (!$trusted) {
        my $events = guard_prune_burst_events($tag, $now);
        push @$events, {
            time    => $now,
            nick    => $nick || '',
            address => $address || '',
            sender    => $sender,
            kind      => $kind || 'PRIVMSG',
            had_query => $query ? 1 : 0,
        };

        if (!$query) {
            my $max_queries = Irssi::settings_get_int('prv_guard_max_queries');
            if ($max_queries > 0
                && guard_query_count_for_server($server) >= $max_queries) {
                guard_activate_mass_lock(
                    $server, $now, $nick, $address,
                    "hard limit of $max_queries open queries", $events
                );
                return 1;
            }
        }

        my %unique = map { $_->{sender} => 1 } @$events;
        my $mass_limit = Irssi::settings_get_int('prv_guard_mass_limit');
        if ($mass_limit > 0 && scalar(keys %unique) >= $mass_limit) {
            my $window = guard_setting_seconds('prv_guard_mass_window');
            $window = 1 if $window <= 0;
            guard_activate_mass_lock(
                $server, $now, $nick, $address,
                scalar(keys %unique) . " untrusted senders in $window s",
                $events
            );
            return 1;
        }
    }

    return 0;
}

# -----------------------------------------------------------------------------
# RAW PRIVMSG: CTCP and ordinary PMs
# -----------------------------------------------------------------------------

sub guard_sig_event_privmsg {
    my ($server, $data, $nick, $address) = @_;

    return unless Irssi::settings_get_bool('prv_guard_enabled');
    return unless $server;

    my ($target, $message) = guard_parse_event_data($data);
    return unless defined $target;
    return unless guard_is_private_target($server, $target);

    my ($command, $pure_action) = guard_ctcp_info($message);
    if (defined($command) && !$pure_action) {
        if (guard_ctcp_mode() ne 'off') {
            if (guard_should_block_ctcp(
                $server, $nick, $address, $message, 'PRIVMSG'
            )) {
                Irssi::signal_stop();
            }
            return;
        }
        # Dedicated CTCP filtering is off, ordinary flood accounting remains on.
    }

    if (guard_should_block_regular_private(
        $server, $nick, $address, 'PRIVMSG'
    )) {
        Irssi::signal_stop();
    }
}

# -----------------------------------------------------------------------------
# RAW NOTICE: CTCP replies
# -----------------------------------------------------------------------------

sub guard_sig_event_notice {
    my ($server, $data, $nick, $address) = @_;

    return unless Irssi::settings_get_bool('prv_guard_enabled');
    return unless $server;

    my ($target, $message) = guard_parse_event_data($data);
    return unless defined $target;
    return unless guard_is_private_target($server, $target);

    my ($command) = guard_ctcp_info($message);
    if (defined($command) && guard_ctcp_mode() ne 'off') {
        if (guard_should_block_ctcp(
            $server, $nick, $address, $message, 'NOTICE'
        )) {
            Irssi::signal_stop();
        }
        return;
    }

    if (guard_should_block_regular_private(
        $server, $nick, $address, 'NOTICE'
    )) {
        Irssi::signal_stop();
    }
}

# -----------------------------------------------------------------------------
# PURGE
# -----------------------------------------------------------------------------

sub guard_stop_purge {
    if ($purge_timer) {
        Irssi::timeout_remove($purge_timer);
        undef $purge_timer;
    }
    undef $purge_state;
}

sub guard_purge_tick {
    return guard_stop_purge() unless $purge_state;

    my $batch = Irssi::settings_get_int('prv_guard_purge_batch');
    $batch = 100 if $batch <= 0;

    my $list  = $purge_state->{queries};
    my $index = $purge_state->{index};
    my $total = $purge_state->{total};

    for (1 .. $batch) {
        last if $index >= $total;
        my $entry = $list->[$index++];
        next unless $entry && defined($entry->{nick});
        my $server = Irssi::server_find_tag($entry->{tag} || '');
        next unless $server;
        my $query = eval { $server->query_find($entry->{nick}) };
        $purge_state->{closed}++ if $query && guard_destroy_query_safe($query);
    }

    $purge_state->{index} = $index;

    if ($index >= $total) {
        my $closed = $purge_state->{closed};
        guard_stop_purge();
        guard_guard_print("emergency cleanup finished; closed $closed queries.");
    }
}

sub guard_start_purge {
    guard_stop_purge();

    # Store only stable identifiers. The actual QUERY is looked up again in
    # each timer tick, avoiding use-after-free when a window closes meanwhile.
    my @queries = map {
        {
            tag  => guard_server_tag($_->{server}),
            nick => $_->{name} || '',
        }
    } grep { $_ && $_->{server} && defined($_->{name}) } Irssi::queries();
    my $total = scalar @queries;

    if (!$total) {
        guard_guard_print('no open queries to close.');
        return;
    }

    $purge_state = {
        queries => \@queries,
        total   => $total,
        index   => 0,
        closed  => 0,
    };

    my $delay = Irssi::settings_get_time('prv_guard_purge_delay');
    $delay = 50 if $delay < 10;

    $purge_timer = Irssi::timeout_add($delay, \&guard_purge_tick, undef);
    guard_guard_print("emergency cleanup: closing $total queries in batches...");
}

# -----------------------------------------------------------------------------
# PERIODIC STATE CLEANUP
# -----------------------------------------------------------------------------

# Flood counters are keyed by network and sender mask. Without periodic
# cleanup, thousands of one-off clone nicks could leave empty hash entries in
# a session running for months. This does not change blocking decisions; it
# only removes counters after their windows and block timers have expired.
sub guard_gc_state {
    my $now = time();

    my $sender_window = guard_setting_seconds('prv_guard_sender_window');
    $sender_window = 3 if $sender_window <= 0;

    my %sender_states = map { $_ => 1 } (
        keys %sender_events,
        keys %sender_started_new,
        keys %sender_block_until,
        keys %sender_notice_until,
    );
    for my $state (keys %sender_states) {
        my $times = $sender_events{$state};
        if (ref($times) eq 'ARRAY') {
            @$times = grep { $_ >= ($now - $sender_window) } @$times;
        }

        delete $sender_block_until{$state}
            if ($sender_block_until{$state} || 0) <= $now;
        delete $sender_notice_until{$state}
            if ($sender_notice_until{$state} || 0) <= $now;

        my $has_times = ref($sender_events{$state}) eq 'ARRAY'
            && @{$sender_events{$state}};
        if (!$has_times && !exists($sender_block_until{$state})) {
            delete $sender_events{$state};
            delete $sender_started_new{$state};
            delete $sender_notice_until{$state};
        }
    }

    my $ctcp_window = guard_setting_seconds('prv_guard_ctcp_sender_window');
    $ctcp_window = 10 if $ctcp_window <= 0;

    my %ctcp_states = map { $_ => 1 } (
        keys %ctcp_sender_events,
        keys %ctcp_sender_block_until,
        keys %ctcp_sender_notice_until,
    );
    for my $state (keys %ctcp_states) {
        my $times = $ctcp_sender_events{$state};
        if (ref($times) eq 'ARRAY') {
            @$times = grep { $_ >= ($now - $ctcp_window) } @$times;
        }

        delete $ctcp_sender_block_until{$state}
            if ($ctcp_sender_block_until{$state} || 0) <= $now;
        delete $ctcp_sender_notice_until{$state}
            if ($ctcp_sender_notice_until{$state} || 0) <= $now;

        my $has_times = ref($ctcp_sender_events{$state}) eq 'ARRAY'
            && @{$ctcp_sender_events{$state}};
        if (!$has_times && !exists($ctcp_sender_block_until{$state})) {
            delete $ctcp_sender_events{$state};
            delete $ctcp_sender_notice_until{$state};
        }
    }

    my $mass_window = guard_setting_seconds('prv_guard_mass_window');
    $mass_window = 1 if $mass_window <= 0;
    for my $tag (keys %burst_events) {
        my $events = $burst_events{$tag};
        next unless ref($events) eq 'ARRAY';
        @$events = grep { $_->{time} >= ($now - $mass_window) } @$events;
        delete $burst_events{$tag}
            if !@$events && ($mass_until{$tag} || 0) <= $now;
    }

    my $ctcp_mass_window = guard_setting_seconds('prv_guard_ctcp_mass_window');
    $ctcp_mass_window = 1 if $ctcp_mass_window <= 0;
    for my $tag (keys %ctcp_events) {
        my $events = $ctcp_events{$tag};
        next unless ref($events) eq 'ARRAY';
        @$events = grep { $_->{time} >= ($now - $ctcp_mass_window) } @$events;
        delete $ctcp_events{$tag}
            if !@$events && ($ctcp_mass_until{$tag} || 0) <= $now;
    }

    for my $key (keys %ctcp_expected_replies) {
        delete $ctcp_expected_replies{$key}
            if ($ctcp_expected_replies{$key} || 0) < $now;
    }

    for my $tag (keys %mass_until) {
        next if ($mass_until{$tag} || 0) > $now;
        guard_finish_mass_lock($tag);
    }
    for my $tag (keys %ctcp_mass_until) {
        next if ($ctcp_mass_until{$tag} || 0) > $now;
        guard_finish_ctcp_mass_lock($tag);
    }

    guard_prune_trusted_queries();
}

# -----------------------------------------------------------------------------
# RESET STATE
# -----------------------------------------------------------------------------

sub guard_clear_guard_state {
    for my $timer (values %mass_timer) {
        Irssi::timeout_remove($timer) if $timer;
    }
    for my $timer (values %ctcp_mass_timer) {
        Irssi::timeout_remove($timer) if $timer;
    }
    for my $timer (values %flood_query_cleanup_timer) {
        Irssi::timeout_remove($timer) if $timer;
    }

    %burst_events             = ();
    %mass_until               = ();
    %mass_blocked             = ();
    %mass_unique              = ();
    %mass_unique_overflow     = ();
    %mass_samples             = ();
    %mass_timer               = ();
    %flood_query_cleanup      = ();
    %flood_query_cleanup_timer = ();
    %sender_events            = ();
    %sender_started_new       = ();
    %sender_block_until       = ();
    %sender_notice_until      = ();

    %ctcp_events              = ();
    %ctcp_mass_until          = ();
    %ctcp_mass_blocked        = ();
    %ctcp_mass_unique         = ();
    %ctcp_mass_unique_overflow = ();
    %ctcp_mass_samples        = ();
    %ctcp_mass_timer          = ();
    %ctcp_sender_events       = ();
    %ctcp_sender_block_until  = ();
    %ctcp_sender_notice_until = ();
    %ctcp_expected_replies    = ();
}

# -----------------------------------------------------------------------------
# COMMANDS
# -----------------------------------------------------------------------------

sub guard_cmd_prvguard {
    my ($data, $server, $witem) = @_;

    $data = '' unless defined $data;
    $data =~ s/^\s+|\s+$//g;
    my ($cmd, $arg) = split /\s+/, lc($data), 2;
    $cmd = 'status' unless defined $cmd && length $cmd;

    if ($cmd eq 'on') {
        Irssi::settings_set_bool('prv_guard_enabled', 1);
        guard_guard_print('protection enabled.');
        return;
    }

    if ($cmd eq 'off') {
        Irssi::settings_set_bool('prv_guard_enabled', 0);
        guard_clear_guard_state();
        guard_guard_print('protection disabled.');
        return;
    }

    if ($cmd eq 'unlock') {
        guard_clear_guard_state();
        guard_guard_print('blocks and counters have been reset.');
        return;
    }

    if ($cmd eq 'purge') {
        guard_start_purge();
        return;
    }

    if ($cmd eq 'ctcp') {
        if (!defined $arg || $arg eq '') {
            guard_guard_print('CTCP mode=' . guard_ctcp_mode() . '; available: off, limit, strict, block.');
            return;
        }

        if ($arg !~ /^(?:off|limit|strict|block)$/) {
            guard_guard_print('Usage: /prvguard ctcp [off|limit|strict|block]');
            return;
        }

        Irssi::settings_set_str('prv_guard_ctcp_mode', $arg);
        guard_clear_guard_state();
        guard_guard_print("CTCP protection mode set to: $arg.");
        return;
    }

    if ($cmd eq 'status') {
        my $enabled = Irssi::settings_get_bool('prv_guard_enabled') ? 'ON' : 'OFF';

        my $mass_limit = Irssi::settings_get_int('prv_guard_mass_limit');
        my $mass_window = guard_setting_seconds('prv_guard_mass_window');
        my $mass_block = guard_setting_seconds('prv_guard_mass_block_time');
        my $sender_limit = Irssi::settings_get_int('prv_guard_sender_limit');
        my $sender_window = guard_setting_seconds('prv_guard_sender_window');
        my $max_queries = Irssi::settings_get_int('prv_guard_max_queries');
        my $auto_close = Irssi::settings_get_bool(
            'prv_guard_auto_close_flood_queries'
        ) ? 'ON' : 'OFF';

        my $ctcp_sender_limit = Irssi::settings_get_int('prv_guard_ctcp_sender_limit');
        my $ctcp_sender_window = guard_setting_seconds('prv_guard_ctcp_sender_window');
        my $ctcp_mass_limit = Irssi::settings_get_int('prv_guard_ctcp_mass_limit');
        my $ctcp_mass_window = guard_setting_seconds('prv_guard_ctcp_mass_window');
        my $ctcp_block = guard_setting_seconds('prv_guard_ctcp_mass_block_time');

        guard_guard_print(
            "version=$VERSION; status=$enabled; PM=$mass_limit new/$mass_window s; " .
            "per sender=$sender_limit/$sender_window s; PM block=$mass_block s; " .
            "max QUERY=$max_queries; auto-close flood QUERY=$auto_close; trusted queries=" . scalar(keys %trusted_queries) . "."
        );
        guard_guard_print(
            "CTCP=" . guard_ctcp_mode() . "; per sender=$ctcp_sender_limit/$ctcp_sender_window s; " .
            "MASS=$ctcp_mass_limit distinct/$ctcp_mass_window s; block=$ctcp_block s; " .
            "PING/VERSION=" .
            (Irssi::settings_get_bool('prv_guard_ctcp_allow_ping_version') ? 'ON' : 'OFF') .
            "; ACTION=/ME handled by the PM filter."
        );

        for my $tag (sort keys %mass_until) {
            my $left = int($mass_until{$tag} - time());
            $left = 0 if $left < 0;
            guard_guard_print("$tag: MASS PM block active for at least $left more s.");
        }
        for my $tag (sort keys %ctcp_mass_until) {
            my $left = int($ctcp_mass_until{$tag} - time());
            $left = 0 if $left < 0;
            guard_guard_print("$tag: MASS CTCP block active for at least $left more s.");
        }
        return;
    }

    guard_guard_print(
        'Usage: /prvguard [status|on|off|unlock|purge|ctcp off|limit|strict|block]'
    );
}

# -----------------------------------------------------------------------------
# REGISTRATION
# -----------------------------------------------------------------------------

Irssi::signal_add_first('event privmsg', \&guard_sig_event_privmsg);
Irssi::signal_add_first('event notice',  \&guard_sig_event_notice);
Irssi::signal_add('query destroyed',     \&guard_sig_query_destroyed);
Irssi::signal_add('message own_private',    \&guard_sig_own_private);
Irssi::signal_add('message irc own_action', \&guard_sig_own_private);
Irssi::signal_add('message nick',           \&guard_sig_nick_changed);
Irssi::signal_add('message quit',        \&guard_sig_message_quit);
Irssi::signal_add('server disconnected', \&guard_sig_server_disconnected);

# Observe outgoing diagnostic CTCP commands without replacing or stopping Irssi's
# native /PING, /VER and /CTCP handlers.
Irssi::signal_add_first('command ping', \&guard_cmd_track_ping);
Irssi::signal_add_first('command ver',  \&guard_cmd_track_ver);
Irssi::signal_add_first('command ctcp', \&guard_cmd_track_ctcp);

Irssi::command_bind('prvguard', \&guard_cmd_prvguard);

# A conversation becomes trusted only after you send a private message.
$guard_gc_timer = Irssi::timeout_add(60_000, \&guard_gc_state, undef);

sub guard_module_unload {
    for my $binding (
        [ 'event privmsg',       \&guard_sig_event_privmsg ],
        [ 'event notice',        \&guard_sig_event_notice ],
        [ 'query destroyed',     \&guard_sig_query_destroyed ],
        [ 'message own_private',    \&guard_sig_own_private ],
        [ 'message irc own_action', \&guard_sig_own_private ],
        [ 'message nick',           \&guard_sig_nick_changed ],
        [ 'message quit',        \&guard_sig_message_quit ],
        [ 'server disconnected', \&guard_sig_server_disconnected ],
        [ 'command ping',        \&guard_cmd_track_ping ],
        [ 'command ver',         \&guard_cmd_track_ver ],
        [ 'command ctcp',        \&guard_cmd_track_ctcp ],
    ) {
        Irssi::signal_remove($binding->[0], $binding->[1]);
    }
    Irssi::command_unbind('prvguard', \&guard_cmd_prvguard);

    Irssi::timeout_remove($guard_gc_timer) if $guard_gc_timer;
    undef $guard_gc_timer;
    guard_clear_guard_state();
    guard_stop_purge();
    %trusted_queries = ();
}

1;
}

# ============================================================================
# prv_status.pl
# ============================================================================
{
my $ITEM = 'prv_status';

# Return the number of currently open private query windows.
sub prvs_query_count {
    my @queries = Irssi::queries();
    return scalar @queries;
}

# Render the statusbar item.
sub prvs_sb_prv_status {
    my ($item, $get_size_only) = @_;

    my $count = prvs_query_count();

    # Requested compact counter: red brackets, bright label, normal-white zero
    # and a bright-white count whenever at least one QUERY is open.
    my $shown_count = $count > 0 ? '%W' . $count : '%K0';
    my $text = '%0%W %R[%WOpen PM%R] ' . $shown_count . '%W %0%W';

    $item->default_handler(
        $get_size_only,
        $text,
        '',
        1
    );
}

# Refresh the statusbar after a query is created or closed.
sub prvs_refresh_prv_status {
    Irssi::statusbar_items_redraw($ITEM);
}

# Register the statusbar item.
Irssi::statusbar_item_register(
    $ITEM,
    0,
    'prvs_sb_prv_status'
);

# Creating a query increases the count.
Irssi::signal_add(
    'query created',
    'prvs_refresh_prv_status'
);

# Closing a query with /wc decreases the count.
Irssi::signal_add(
    'query destroyed',
    'prvs_refresh_prv_status'
);

# Open PM is rendered beside each network header; remove any legacy standalone item.
Irssi::command("^statusbar removeitem $ITEM window");

prvs_refresh_prv_status();

# The window bar is restored centrally from its pre-load snapshot. Do not
# REMOVEITEM here: prv_status is normally absent and Irssi would print noise.
sub prvs_module_unload {
    Irssi::signal_remove('query created', 'prvs_refresh_prv_status');
    Irssi::signal_remove('query destroyed', 'prvs_refresh_prv_status');
}
}

# ============================================================================
# network_statusbars.pl
# ============================================================================
{
my $SCRIPT_VERSION   = '2.1.1';
my $OPER_WHO_TIMEOUT = 20_000;

# XTerm mouse state. Irssi receives ESC [ M through a key binding, followed by
# three bytes: button, column and row. Coordinates are converted to zero-based.
my $netbar_mouse_status = -1;
my @netbar_mouse_combo = (3, 0, 0);
my @netbar_mouse_previous = (3, 0, 0);

# Modern terminals normally use SGR 1006: CSI < button;x;y M/m. Unlike the
# legacy three-byte VT200 sequence it does not stop at column/row 223. Keep the
# old parser as a fallback for terminals which ignore DECSET 1006.
my $netbar_mouse_sgr_status = -1;
my $netbar_mouse_sgr_buffer = '';
my @netbar_mouse_sgr_press;
my $netbar_mouse_sgr_press_time = 0;
my $netbar_mouse_drag;
my $netbar_mouse_press_target;
# One expiring, data-only click; no native line/view objects or extra timer.
my $netbar_mouse_chat_click;
my $netbar_channel_swap_undo;

# Bound geometry accepted from the PTY. Besides avoiding pathological memory
# use, this keeps the sidebar inside Irssi's own 4096-cell rendering limit.
my $MATRIX_MAX_TERMINAL_COLUMNS = 4096;
my $MATRIX_MAX_TERMINAL_ROWS    = 2048;
my $MATRIX_MAX_MATRIX_COLUMNS   = 1024;
my $MATRIX_MAX_MATRIX_ROWS      = 512;

my $netbar_mouse_timeout_tag;
my $netbar_mouse_resize_tag;
my $netbar_mouse_screen_width = 0;
my $netbar_mouse_screen_height = 0;
my $netbar_mouse_layout_width = 0;
my $netbar_mouse_layout_height = 0;
my $netbar_mouse_installed = 0;
my $netbar_mouse_tracking = 0;
my $netbar_mouse_binding_conflict = 0;
my $netbar_mouse_pending_close_key = '';
my $netbar_mouse_pending_close_until = 0;

# Short raw-terminal Matrix overlay used for a left-click transition from an
# active channel, Status 1 or QUERY to a channel and once after /SCRIPT LOAD.
# Keyboard navigation, PM-to-PM and right-click /wc deliberately bypass it.
my $netbar_matrix_timer_tag;
my $netbar_matrix_startup_timer_tag;
my $netbar_matrix_running = 0;
my $netbar_matrix_frame = 0;
my $netbar_matrix_frames = 7;
my $netbar_matrix_columns = 0;
my $netbar_matrix_rows = 0;
my $netbar_matrix_left_column = 0;
my $netbar_matrix_target_tag = '';
my $netbar_matrix_target_name = '';
my @netbar_matrix_heads;
my @netbar_matrix_speeds;
my @netbar_matrix_trails;

# Matrix input effect. Printable keys are intercepted before Irssi inserts them.
# One random bright-green one-cell glyph is shown immediately at the real input
# cursor, changes a few times, then the original key is replayed into Irssi.
# A small FIFO preserves exact typing order even during rapid input.
my $netbar_matrix_input_timer_tag;
my @netbar_matrix_input_queue;
my $netbar_matrix_input_current_key;
my $netbar_matrix_input_current_started = 0;
my $netbar_matrix_input_replaying = 0;
my $netbar_matrix_input_overlay_active = 0;
my $netbar_matrix_input_escape_state = 0;
my $netbar_matrix_input_escape_prefix = '';
my $netbar_matrix_input_escape_buffer = '';
my $netbar_matrix_input_bracketed_paste = 0;
my $netbar_matrix_input_max_queue = 128;
my $netbar_matrix_input_manual_test_tag;
my $netbar_matrix_input_last_physical_time = 0;
my $netbar_matrix_input_burst_count = 0;
my $netbar_matrix_input_burst_started = 0;
my $netbar_matrix_input_paste_until = 0;
my $netbar_matrix_cursor_controller_active = 0;
my $netbar_matrix_cursor_apply_tag;
my $netbar_matrix_cursor_last_state = '';
my $netbar_matrix_screen_attach_watch_tag;
my $netbar_matrix_screen_attach_recover_tag;
my $netbar_matrix_screen_socket_path = '';
my $netbar_matrix_screen_attached;
my $netbar_matrix_screen_socket_change;
my $netbar_matrix_screen_observed_change;

my %last_channel_by_network;
# Last channel or QUERY actually viewed on each network. Ctrl-X uses this
# pointer to return to the same conversation when cycling between networks.
my %last_item_by_network;
my %message_activity;
# Incoming text/actions since this channel was last opened; keyed by network/name.
my %channel_unread_count;
my %nick_attention;
my %status_attention_by_network;
# Internal AWAY/GAWAY command feedback and the IRC server acknowledgements it
# triggers must not look like unread Status traffic. Return reports mark their
# own network explicitly after this generic print hook has been silenced.
my $status_attention_suppression_depth = 0;
my %status_attention_quiet_until_by_network;

# Persistent mute state. Keys use the same per-network/channel identity as the
# sidebar activity maps, so #atw on IRCnet and IRCnet2 remain independent.
my %muted_channels;

# One in-memory mention journal per connected server while that server is AWAY.
# It intentionally is not written to disk. A sender classified as spam loses all
# stored message bodies immediately; the return report then contains only one
# spam summary line for that sender.
my %away_mention_sessions;

# Only AWAY states created by the idle timer are owned by the automation. Manual
# /away and /gaway states are deliberately not stored here and are never removed
# when the user later speaks on a channel.
my %auto_away_servers;
my $auto_away_timer_tag;
my $last_channel_activity_at = time();

my $refresh_timer;
my $force_layout = 1;

# Global IRC-operator cache. Lowercase WHO 0 o is used; local +O users are not
# queried with /wii. JOIN bursts are debounced, so a massjoin never creates
# one WHO/WHOIS request per user.
my %oper_nicks_by_tag;
my %oper_who_collect;
my %oper_who_active;
# WHO 352 has no request mask. Correlate it with the FIFO of WHO commands
# actually sent, including native channel sync, /WHO and other scripts.
my %oper_who_wire_queue;
my %oper_who_wire_desynced;
my %oper_who_last_sent;
my $oper_who_sending_identity = '';

my %oper_who_timeout_tag;
my %oper_join_timer_tag;
my %oper_backoff_until;
my %oper_failures;
my $oper_periodic_timer_tag;
my %oper_channel_cache;
my %oper_membership_generation; # server tag => generation

my $SIDEBAR_MIN_CONVERSATION_WIDTH = 21;
my $SIDEBAR_DIVIDER_WIDTH = 1;
my $CHANNEL_PREFIX_LENGTH = 5;

# Native left-hand MATRIX split retained as the default layout.
my $SIDEBAR_NAME = 'MATRIX-SIDEBAR';
my $netbar_width_override;
my $netbar_sidebar_last_content_refnum = 0;
my $netbar_sidebar_layout_ready = 0;
my $netbar_sidebar_layout_guard = 0;
my $netbar_sidebar_focus_timer;
my $netbar_sidebar_normalise_timer;
my $netbar_sidebar_recovery_timer;
my $netbar_sidebar_recovery_network = '';
my $netbar_sidebar_shutting_down = 0;
my $netbar_sidebar_last_signature = '';
# Cache text only. Line wrappers borrow native storage and must never survive
# a callback: /SCROLLBACK CLEAR, pruning or another script can free that storage.
my @netbar_sidebar_buffer_raw;
my @netbar_sidebar_buffer_text;
my $netbar_sidebar_buffer_width = -1;
my $netbar_sidebar_buffer_height = -1;
my $netbar_sidebar_created_by_script = 0;
my $netbar_sidebar_warned = 0;
my $netbar_sidebar_last_forced_width = -1;
my $netbar_sidebar_auto_width = 0;
my $netbar_sidebar_width_timer;
my $netbar_sidebar_width_attempts = 0;
my $netbar_sidebar_measuring = 0;
my $netbar_sidebar_theme_applied_refnum = 0;
my $netbar_sidebar_chrome_ready = 0;
my $netbar_native_ui_restored = 0;
# Explicit /netbars clean|restore retains native identity beside the panel.
# Default appearance remains unchanged; this choice lasts until script unload.
my $netbar_keep_native_identity = 0;
my $netbar_sidebar_observed_width = -1;
my $netbar_sidebar_observed_height = -1;
my $netbar_sidebar_autostick_saved = 0;
my $netbar_sidebar_autostick_owned = 0;
my @netbar_sidebar_mouse_rows;
my $netbar_sidebar_mouse_width = -1;
my $netbar_sidebar_mouse_height = -1;

# Sidebar mode deliberately owns the only split layout: the technical panel is
# fixed on the left and every ordinary Irssi window is selected in the right
# pane.  Irssi's default autostick_split_windows associates newly created
# channel windows with whichever split was active at creation time.  After a
# /PART that stale association could later put a conversation over the panel.
# Temporarily use non-sticky ordinary windows while sidebar mode is active and
# restore the user's setting when the sidebar is removed or the script unloads.
sub netbar_sidebar_disable_autostick {
    if (!$netbar_sidebar_autostick_owned) {
        $netbar_sidebar_autostick_saved =
            Irssi::settings_get_bool('autostick_split_windows') ? 1 : 0;
        $netbar_sidebar_autostick_owned = 1;
    }
    Irssi::settings_set_bool('autostick_split_windows', 0)
        if Irssi::settings_get_bool('autostick_split_windows');
}

sub netbar_sidebar_restore_autostick {
    return unless $netbar_sidebar_autostick_owned;
    Irssi::settings_set_bool(
        'autostick_split_windows',
        $netbar_sidebar_autostick_saved ? 1 : 0,
    );
    $netbar_sidebar_autostick_owned = 0;
}

sub netbar_sidebar_unstick_window {
    my ($window) = @_;
    return unless $window && defined($window->{refnum});
    return if netbar_sidebar_is_window($window);
    Irssi::command(
        '^window stick ' . int($window->{refnum}) . ' off'
    );
}

sub netbar_sidebar_unstick_content_windows {
    for my $window (Irssi::windows()) {
        netbar_sidebar_unstick_window($window);
    }
}

sub netbar_sidebar_forget_buffer {
    @netbar_sidebar_buffer_raw = ();
    @netbar_sidebar_buffer_text = ();
    $netbar_sidebar_buffer_width = -1;
    $netbar_sidebar_buffer_height = -1;
}

sub netbar_sidebar_is_window {
    my ($window) = @_;
    return 0 unless $window;
    my $name = defined($window->{name}) ? $window->{name} : '';
    return lc($name) eq lc($SIDEBAR_NAME) ? 1 : 0;
}

sub netbar_sidebar_window {
    return eval { Irssi::window_find_name($SIDEBAR_NAME) };
}

sub netbar_sidebar_content_window {
    # The user's currently active content window is authoritative. A remembered
    # refnum is only a fallback while the technical sidebar itself temporarily
    # owns focus. Preferring the old refnum here made delayed layout timers jump
    # back to a channel window which Irssi had already left after /PART.
    my $active = Irssi::active_win();
    if ($active && !netbar_sidebar_is_window($active)) {
        $netbar_sidebar_last_content_refnum = int($active->{refnum} // 0);
        return $active;
    }

    if ($netbar_sidebar_last_content_refnum) {
        my $remembered = eval {
            Irssi::window_find_refnum($netbar_sidebar_last_content_refnum)
        };
        return $remembered if $remembered && !netbar_sidebar_is_window($remembered);
    }

    my $status = eval { Irssi::window_find_refnum(1) };
    return $status if $status && !netbar_sidebar_is_window($status);

    for my $window (Irssi::windows()) {
        next unless $window && !netbar_sidebar_is_window($window);
        return $window;
    }
    return undef;
}

sub netbar_sidebar_prepare_black_theme {
    return 1 if $netbar_sidebar_theme_ready;

    my $irssi_dir = eval { Irssi::get_irssi_dir() };
    $irssi_dir = ($ENV{HOME} || '.') . '/.irssi'
        unless defined($irssi_dir) && length($irssi_dir);

    if (!-d $irssi_dir) {
        mkdir $irssi_dir or return 0;
    }

    my $flags = O_WRONLY | O_CREAT | O_EXCL | $MATRIX_O_NOFOLLOW;
    my ($fh, $path, $name);
    for (1 .. 8) {
        # Reuse one opaque name in this Irssi process: native theme objects
        # stay cached after /window theme -delete. A new name on every load
        # otherwise grows that cache without bound. Retain only a string.
        my $cached_name = $Irssi::MATRIX_SIDEBAR_THEME_NAME // '';
        $name = $_ == 1 && $cached_name =~ /\Amatrix_sidebar_black_\d+_\d+\z/
            ? $cached_name
            : 'matrix_sidebar_black_' . $$ . '_'
                . sprintf('%09d', int(rand(1_000_000_000)));
        $path = $irssi_dir . '/' . $name . '.theme';
        undef $fh;
        last if sysopen($fh, $path, $flags, 0600);
    }
    return 0 unless $fh && defined($path) && length($path);

    # Irssi atomically replaces theme files on /SAVE. Keep an ownership
    # marker in the parsed config so cleanup can recognize that replacement.
    print {$fh} 'matrix_sidebar_owner = "' . $name . '";' . "\n";
    print {$fh} <<'THEME';
abstracts = {
  sb_background = "%0%w";
  sb_window_bg = "%0%w";
  sb_window_inact_bg = "%0%w";
  sb_default_bg = "%0%w";
  sb_prompt_bg = "%0%w";
  sb_topic_bg = "%0%w";
};
THEME
    if (!close($fh)) {
        unlink $path if defined($path) && -f $path && !-l $path;
        return 0;
    }

    my @created = lstat($path);
    if (!@created || -l _ || !-f _ || $created[4] != $<) {
        unlink $path if @created && !-l _ && -f _ && $created[4] == $<;
        return 0;
    }

    $Irssi::MATRIX_SIDEBAR_THEME_NAME = $name;
    $netbar_sidebar_theme_name = $name;
    $netbar_sidebar_theme_path = $path;
    $netbar_sidebar_theme_device = $created[0];
    $netbar_sidebar_theme_inode = $created[1];
    $netbar_sidebar_theme_owned = 1;
    $netbar_sidebar_theme_ready = 1;
    return 1;
}

sub netbar_sidebar_remove_black_theme_file {
    if ($netbar_sidebar_theme_owned && length($netbar_sidebar_theme_path)) {
        my @current = lstat($netbar_sidebar_theme_path);
        if (@current && !-l _ && -f _ && $current[4] == $<) {
            my $owned = $current[0] == $netbar_sidebar_theme_device
                && $current[1] == $netbar_sidebar_theme_inode;
            if (!$owned && $current[7] <= 1_048_576) {
                # /SAVE uses rename(), so the inode can legitimately change.
                # Read only a regular file owned by us, never follow a link,
                # and remove it only if the private marker still matches.
                if (sysopen(my $fh, $netbar_sidebar_theme_path,
                            O_RDONLY | $MATRIX_O_NOFOLLOW)) {
                    my @opened = stat($fh);
                    if (@opened && $opened[0] == $current[0]
                            && $opened[1] == $current[1]) {
                        local $/;
                        my $text = <$fh>;
                        $owned = defined($text)
                            && (matrix_config_scalar($text, 'matrix_sidebar_owner') // '')
                                eq $netbar_sidebar_theme_name;
                    }
                    close $fh;
                }
            }
            my @latest = lstat($netbar_sidebar_theme_path);
            unlink $netbar_sidebar_theme_path
                if $owned && @latest && !-l _ && -f _
                    && $latest[0] == $current[0] && $latest[1] == $current[1];
        }
    }

    $netbar_sidebar_theme_name = '';
    $netbar_sidebar_theme_path = '';
    $netbar_sidebar_theme_device = -1;
    $netbar_sidebar_theme_inode = -1;
    $netbar_sidebar_theme_owned = 0;
    $netbar_sidebar_theme_ready = 0;
}

sub netbar_sidebar_apply_black_theme {
    my ($sidebar, $content) = @_;
    return unless $sidebar && defined($sidebar->{refnum});
    my $sidebar_ref = int($sidebar->{refnum});
    return if $netbar_sidebar_theme_applied_refnum == $sidebar_ref;
    return unless netbar_sidebar_prepare_black_theme();

    $content ||= netbar_sidebar_content_window();
    $netbar_sidebar_layout_guard = 1;
    Irssi::command('^window goto ' . $sidebar_ref);
    Irssi::command('^window theme ' . $netbar_sidebar_theme_name);
    $netbar_sidebar_theme_applied_refnum = $sidebar_ref;
    if ($content && defined($content->{refnum})) {
        Irssi::command('^window goto ' . int($content->{refnum}));
    }
    $netbar_sidebar_layout_guard = 0;
}

# Keep the technical sidebar away from the ordinary low window numbers.  When
# the script starts before /CONNECT, Status is window 1 and the freshly created
# hidden sidebar would otherwise consume window 2.  The first real channel
# would then incorrectly begin at 3/#channel.  A sticky window number in this
# reserved high range leaves refnum 2 available for the first channel and is
# not affected by windows_auto_renumber.
sub netbar_sidebar_reserve_refnum {
    my ($sidebar) = @_;
    return 0 unless $sidebar && defined($sidebar->{refnum});

    my %used = map {
        defined($_->{refnum}) ? (int($_->{refnum}) => 1) : ()
    } Irssi::windows();

    my $current_ref = int($sidebar->{refnum});
    return $current_ref if $current_ref >= 900 && $current_ref <= 999;

    for my $candidate (reverse 900 .. 999) {
        next if $used{$candidate};
        Irssi::command('^window goto ' . $current_ref);
        Irssi::command('^window number -sticky ' . $candidate);

        my $renumbered = netbar_sidebar_window();
        return int($renumbered->{refnum})
            if $renumbered && defined($renumbered->{refnum});
        last;
    }

    return $current_ref;
}

sub netbar_sidebar_create_window {
    my $sidebar = netbar_sidebar_window();
    if ($sidebar) {
        netbar_sidebar_reserve_refnum($sidebar);
        return netbar_sidebar_window() || $sidebar;
    }

    my $content = netbar_sidebar_content_window();
    my %before = map {
        defined($_->{refnum}) ? (int($_->{refnum}) => 1) : ()
    } Irssi::windows();

    $netbar_sidebar_layout_guard = 1;
    Irssi::command('^window new hidden');

    my ($created) = grep {
        defined($_->{refnum}) && !$before{int($_->{refnum})}
    } Irssi::windows();
    $created ||= Irssi::active_win();

    if (!$created || !defined($created->{refnum})) {
        $netbar_sidebar_layout_guard = 0;
        return undef;
    }

    netbar_sidebar_forget_buffer();
    my $refnum = int($created->{refnum});
    Irssi::command('^window goto ' . $refnum);
    Irssi::command('^window name ' . $SIDEBAR_NAME);
    $refnum = netbar_sidebar_reserve_refnum($created) || $refnum;
    Irssi::command('^window immortal on');
    Irssi::command('^window level NONE');
    Irssi::command('^window hidelevel ALL');
    Irssi::command('^window log off');
    $netbar_sidebar_created_by_script = 1;

    if ($content && defined($content->{refnum})
        && int($content->{refnum}) != $refnum) {
        $netbar_sidebar_last_content_refnum = int($content->{refnum});
        Irssi::command('^window goto ' . int($content->{refnum}));
    }

    $netbar_sidebar_layout_guard = 0;
    my $created_sidebar = netbar_sidebar_window();
    netbar_sidebar_apply_black_theme($created_sidebar, $content) if $created_sidebar;
    return $created_sidebar;
}

# Sidebar mode owns exactly two visible panes: the permanent MATRIX-SIDEBAR and
# one conversation window. Other Irssi windows remain open and addressable by
# refnum, but must stay hidden instead of accumulating as additional splits.
sub netbar_sidebar_hide_other_splits {
    my ($sidebar, $content) = @_;
    return unless $sidebar && $content;
    return unless defined($sidebar->{refnum}) && defined($content->{refnum});

    my $sidebar_ref = int($sidebar->{refnum});
    my $content_ref = int($content->{refnum});
    for my $window (Irssi::windows()) {
        next unless $window && defined($window->{refnum});
        my $refnum = int($window->{refnum});
        next if $refnum == $sidebar_ref || $refnum == $content_ref;
        Irssi::command('^window hide ' . $refnum);
    }
}

sub netbar_sidebar_apply_chrome {
    return if $matrix_unloading;
    return unless Irssi::settings_get_bool('network_statusbars_enabled');
    return if $netbar_sidebar_chrome_ready;

    # Disable historical MATRIX rows before building the clock-only footer.
    # Force cleanup on every chrome rebuild: /RELOAD may reintroduce saved
    # historical bars even after this process cleaned them once at startup.
    matrix_disable_legacy_statusbars(1);

    # The native window/window_inact bars are replaced while MATRIX is loaded.
    # Only the black clock, separator spacing and the prompt remain below the
    # conversation. UNLOAD resets the native Irssi bars atomically.
    for my $bar ($netbar_keep_native_identity ? () : qw(window window_inact)) {
        for my $item (qw(
            barstart barend time user window window_empty lag act more
            matrix_dark_fill matrix_sidebar_clock matrix_sidebar_clock_pm
            matrix_sidebar_act_overflow pm_unread prv_status
        )) {
            Irssi::command('^statusbar removeitem ' . $item . ' ' . $bar);
        }
        Irssi::command('^statusbar modify -disable ' . $bar);
    }

    for my $bar (qw(topic)) {
        Irssi::command('^statusbar modify -disable ' . $bar);
    }

    # Topic only above the active conversation split, never above the sidebar.
    my $topic_bar = 'matrix_sidebar_topic_bar';
    Irssi::command(
        '^statusbar add -disable -type window -placement top '
        . '-position 0 -visible active ' . $topic_bar
    );
    for my $item (qw(
        barstart barend topic topic_empty matrix_sidebar_topic matrix_dark_fill
    )) {
        Irssi::command('^statusbar removeitem ' . $item . ' ' . $topic_bar);
    }
    Irssi::command(
        '^statusbar additem -alignment left -priority 1000 '
        . 'matrix_sidebar_topic ' . $topic_bar
    );
    Irssi::command(
        '^statusbar modify -nodisable -type window -placement top '
        . '-position 0 -visible active ' . $topic_bar
    );

    # One fully black row between topic and public chat. Keeping it as a real
    # window statusbar makes the separation stable across resize and reload.
    my $topic_gap_bar = 'matrix_sidebar_topic_gap_bar';
    Irssi::command(
        '^statusbar add -disable -type window -placement top '
        . '-position 1 -visible active ' . $topic_gap_bar
    );
    for my $item (qw(
        barstart barend topic topic_empty time user window window_empty lag act more
        matrix_sidebar_topic matrix_dark_fill matrix_sidebar_blank_row
        matrix_sidebar_blue_separator pm_unread prv_status
    )) {
        Irssi::command(
            '^statusbar removeitem ' . $item . ' ' . $topic_gap_bar
        );
    }
    Irssi::command(
        '^statusbar additem -alignment left -priority 1000 '
        . 'matrix_sidebar_blank_row ' . $topic_gap_bar
    );
    Irssi::command(
        '^statusbar modify -nodisable -type window -placement top '
        . '-position 1 -visible active ' . $topic_gap_bar
    );

    # Retire the historical pre-Act gap bar.
    my $clock_bar = 'matrix_sidebar_act_gap_bar';
    Irssi::command('^statusbar add -disable ' . $clock_bar);
    for my $item (qw(
        barstart barend time user window window_empty lag act more
        matrix_sidebar_clock matrix_sidebar_clock_pm
        matrix_sidebar_act_overflow matrix_dark_fill pm_unread prv_status
    )) {
        Irssi::command('^statusbar removeitem ' . $item . ' ' . $clock_bar);
    }
    Irssi::command('^statusbar modify -disable ' . $clock_bar);

    # Three explicit rows: black gap, topic-blue dividing line, black gap.
    # Equal one-row spacing on each side of blue remains stable after /RELOAD.
    my $chat_top_gap_bar = 'matrix_sidebar_chat_top_gap_bar';
    Irssi::command(
        '^statusbar add -disable -type window -placement bottom '
        . '-position 95 -visible active ' . $chat_top_gap_bar
    );
    for my $item (qw(
        barstart barend time user window window_empty lag act more input
        matrix_prompt_context matrix_sidebar_clock matrix_sidebar_clock_pm
        matrix_sidebar_act_overflow matrix_dark_fill matrix_sidebar_blank_row
        matrix_sidebar_blue_separator pm_unread prv_status
    )) {
        Irssi::command('^statusbar removeitem ' . $item . ' ' . $chat_top_gap_bar);
    }
    Irssi::command(
        '^statusbar additem -alignment left -priority 1000 '
        . 'matrix_sidebar_blank_row ' . $chat_top_gap_bar
    );
    Irssi::command(
        '^statusbar modify -nodisable -type window -placement bottom '
        . '-position 95 -visible active ' . $chat_top_gap_bar
    );

    my $blue_bar = 'matrix_sidebar_blue_separator_bar';
    Irssi::command(
        '^statusbar add -disable -type window -placement bottom '
        . '-position 96 -visible active ' . $blue_bar
    );
    for my $item (qw(
        barstart barend time user window window_empty lag act more input
        matrix_prompt_context matrix_sidebar_clock matrix_sidebar_clock_pm
        matrix_sidebar_act_overflow matrix_dark_fill matrix_sidebar_blank_row
        matrix_sidebar_blue_separator pm_unread prv_status
    )) {
        Irssi::command('^statusbar removeitem ' . $item . ' ' . $blue_bar);
    }
    Irssi::command(
        '^statusbar additem -alignment left -priority 1000 '
        . 'matrix_sidebar_blue_separator ' . $blue_bar
    );
    Irssi::command(
        '^statusbar modify -nodisable -type window -placement bottom '
        . '-position 96 -visible active ' . $blue_bar
    );

    # Row 97 is the matching lower blank row before the clock.
    my $chat_gap_bar = 'matrix_sidebar_chat_gap_bar';
    Irssi::command(
        '^statusbar add -disable -type window -placement bottom '
        . '-position 97 -visible active ' . $chat_gap_bar
    );
    for my $item (qw(
        barstart barend time user window window_empty lag act more input
        matrix_prompt_context matrix_sidebar_clock matrix_sidebar_clock_pm
        matrix_sidebar_act_overflow matrix_dark_fill matrix_sidebar_blank_row
        matrix_sidebar_blue_separator pm_unread prv_status
    )) {
        Irssi::command(
            '^statusbar removeitem ' . $item . ' ' . $chat_gap_bar
        );
    }
    Irssi::command(
        '^statusbar additem -alignment left -priority 1000 '
        . 'matrix_sidebar_blank_row ' . $chat_gap_bar
    );
    Irssi::command(
        '^statusbar modify -nodisable -type window -placement bottom '
        . '-position 97 -visible active ' . $chat_gap_bar
    );

    # The clock has its own black row. Neither former Act bar is enabled.
    my $clock_only_bar = 'matrix_clock_bar';
    Irssi::command('^statusbar add -disable -type window -placement bottom '
        . '-position 98 -visible active ' . $clock_only_bar);
    for my $item (qw(barstart barend time user window window_empty lag act more
            matrix_sidebar_clock matrix_sidebar_clock_pm matrix_sidebar_act_overflow
            matrix_sidebar_context matrix_dark_fill pm_unread prv_status)) {
        Irssi::command('^statusbar removeitem ' . $item . ' ' . $clock_only_bar);
    }
    Irssi::command('^statusbar additem -alignment left -priority 1000 '
        . 'matrix_sidebar_clock ' . $clock_only_bar);
    Irssi::command('^statusbar additem -alignment left -after matrix_sidebar_clock '
        . '-priority 0 matrix_dark_fill ' . $clock_only_bar);
    Irssi::command('^statusbar modify -nodisable -type window -placement bottom '
        . '-position 98 -visible active ' . $clock_only_bar);

    # Row 100 is the real input line, visible only in the active split. Focus
    # restoration keeps that split on the conversation side.
    Irssi::command(
        '^statusbar modify -nodisable -type window -placement bottom '
        . '-position 100 -visible active prompt'
    );
    matrix_apply_dynamic_prompt();

    Irssi::statusbar_items_redraw('matrix_sidebar_topic');
    matrix_redraw_bottom_rows_now(1);
    matrix_restore_saved_statusbars(qw(window window_inact))
        if $netbar_keep_native_identity;
    $netbar_sidebar_chrome_ready = 1;
}

sub netbar_sidebar_reapply_chrome {
    $netbar_sidebar_chrome_ready = 0;
    netbar_sidebar_apply_chrome();
}

sub netbar_sidebar_restore_chrome {
    $netbar_sidebar_chrome_ready = 0;
    # ADD -disable is idempotent and safe even if a bar does not yet exist.
    # Also remove every historical MATRIX row so a reload cannot resurrect an
    # Act-only bar from an older release.
    matrix_disable_owned_statusbars();
}

sub netbar_sidebar_measure_width {
    # Build the one-column sidebar against a generous virtual width and measure
    # the longest rendered row. The split ends two blank cells after that row,
    # followed by the one-cell divider. Recalculate from the channels which are
    # currently present: joining a long name may grow the split, while closing
    # that channel must allow it to return to its normal minimum width.
    return $netbar_sidebar_auto_width if $netbar_sidebar_measuring;

    $netbar_sidebar_measuring = 1;
    my $saved_override = $netbar_width_override;
    $netbar_width_override = 200;
    my @rows = netbar_build_layout_rows();
    $netbar_width_override = $saved_override;
    $netbar_sidebar_measuring = 0;

    my $longest_channel = 0;
    my $longest_header  = 0;
    for my $row (@rows) {
        next unless $row;
        my $key = $row->{key} // '';
        my $length = int($row->{plain_length} // 0);
        if ($key =~ /\0(?:channels|query)\0/) {
            $length = $row->{width_requirement} if defined($row->{width_requirement});
            $longest_channel = $length if $length > $longest_channel;
        }
        elsif ($key =~ /\0(?:header|status)$/) {
            $longest_header = $length if $length > $longest_header;
        }
    }

    # Include hidden PM pages too, so wheel paging never resizes the split.
    for my $network (netbar_connected_networks()) {
        for my $entry (matrix_pm_entries_for_network($network)) {
            my $length = $CHANNEL_PREFIX_LENGTH + 2 + matrix_text_width(
                $entry->{refnum} . '/' . $entry->{plain_nick} . ' (999) · 9999d');
            $longest_channel = $length if $length > $longest_channel;
        }
    }

    # Channel display data reserves mode/operator space for every open channel,
    # so this value remains stable while navigating. It changes only when the
    # actual set of rows changes (for example after JOIN or channel close).
    # The complete Network/NET title is also a real width requirement. This
    # keeps its labels complete on a normal terminal instead of abbreviating
    # them to N/I/O. The terminal-width cap below still protects the minimum
    # conversation area on exceptionally narrow screens.
    my $longest_title = 0;
    for my $row (@rows) {
        next unless $row && ($row->{key} // '') eq '__sidebar_title__';
        my $length = int($row->{plain_length} // 0);
        $longest_title = $length if $length > $longest_title;
    }

    my $longest = $longest_channel || $longest_header;
    $longest = $longest_title if $longest_title > $longest;
    my $measured = $longest + 3; # two blanks + invisible split divider
    $measured = 24 if $measured < 24;
    $measured = 80 if $measured > 80;

    $netbar_sidebar_auto_width = $measured;
    return $netbar_sidebar_auto_width;
}

sub netbar_sidebar_desired_width {
    my $automatic = Irssi::settings_get_bool(
        'network_statusbars_sidebar_auto_width'
    );
    my $width;
    if ($automatic) {
        $width = netbar_sidebar_measure_width();
    }
    else {
        $width = Irssi::settings_get_int('network_statusbars_sidebar_width');
        $width = 34 if $width < 20;
        $width = 80 if $width > 80;
    }

    # A long channel can request up to 80 columns, but Irssi cannot honour that
    # when the terminal would leave too little room for the conversation pane.
    # Previously the watchdog compared the impossible request with Irssi's
    # clamped split once per second and retriggered SIZE/GROW forever. Derive the
    # real terminal width from the two visible panes (or from the unsplit
    # content window during initial creation) and cap the request before the
    # first resize. Normal terminals keep the exact historical width.
    my $content = netbar_sidebar_content_window();
    my $sidebar = netbar_sidebar_window();
    my $terminal_width = 0;

    if ($netbar_sidebar_layout_ready
        && $sidebar && $content
        && defined($sidebar->{refnum}) && defined($content->{refnum})
        && int($sidebar->{refnum}) != int($content->{refnum})) {
        my $sidebar_width = int($sidebar->{width} // 0);
        my $content_width = int($content->{width} // 0);
        if ($sidebar_width > 0 && $content_width > 0) {
            $terminal_width = $sidebar_width + $content_width
                + $SIDEBAR_DIVIDER_WIDTH;
        }
    }

    if ($terminal_width <= 0 && $content) {
        $terminal_width = int($content->{width} // 0);
    }

    if ($terminal_width > 0) {
        my $maximum = $terminal_width
            - $SIDEBAR_MIN_CONVERSATION_WIDTH
            - $SIDEBAR_DIVIDER_WIDTH;

        # Extremely small terminals cannot accommodate both normal minimums.
        # In that case use a stable half-width split instead of requesting an
        # impossible size on every watchdog tick.
        if ($maximum < 20) {
            $maximum = int(($terminal_width - $SIDEBAR_DIVIDER_WIDTH) / 2);
            $maximum = 1 if $maximum < 1;
        }
        $width = $maximum if $width > $maximum;
    }

    return $width;
}

sub netbar_sidebar_cancel_width_timer {
    if (defined $netbar_sidebar_width_timer) {
        Irssi::timeout_remove($netbar_sidebar_width_timer);
        $netbar_sidebar_width_timer = undef;
    }
}

sub netbar_sidebar_resize_restore_window {
    my ($fallback) = @_;

    # Width changes are performed by briefly activating the technical sidebar.
    # Restore the window which is active *now*, not an older remembered refnum.
    # This matters after /PART: Irssi may already have moved to another window,
    # while the parted channel window can still exist as an empty container.
    my $active = Irssi::active_win();
    if ($active
        && !netbar_sidebar_is_window($active)
        && defined($active->{refnum})) {
        return $active;
    }

    return $fallback
        if $fallback
        && !netbar_sidebar_is_window($fallback)
        && defined($fallback->{refnum});

    return netbar_sidebar_content_window();
}

sub netbar_sidebar_recheck_width {
    $netbar_sidebar_width_timer = undef;

    my $sidebar = netbar_sidebar_window();
    my $content = netbar_sidebar_content_window();
    my $restore = netbar_sidebar_resize_restore_window($content);
    return unless $sidebar && $restore;
    return unless defined($sidebar->{refnum}) && defined($restore->{refnum});

    my $desired = netbar_sidebar_desired_width();
    my $actual = int($sidebar->{width} // 0);
    return if $actual > 0 && abs($actual - $desired) <= 1;

    $netbar_sidebar_layout_guard = 1;
    Irssi::command('^window goto ' . int($sidebar->{refnum}));

    # SIZE is the canonical Irssi command. GROW/SHRINK is a second path for
    # builds/terminal resizes where the first layout pass is immediately
    # rebalanced back to 50/50.
    if ($actual > 0) {
        my $delta = $actual - $desired;
        if ($delta > 0) {
            Irssi::command('^window shrink -right ' . $delta);
        }
        elsif ($delta < 0) {
            Irssi::command('^window grow -right ' . (-$delta));
        }
    }
    else {
        Irssi::command('^window size -right ' . $desired);
    }

    Irssi::command('^window goto ' . int($restore->{refnum}));
    $netbar_sidebar_layout_guard = 0;
    $netbar_sidebar_last_content_refnum = int($restore->{refnum});
    $netbar_sidebar_last_signature = '';

    if (++$netbar_sidebar_width_attempts < 4) {
        $netbar_sidebar_width_timer = Irssi::timeout_add_once(
            120,
            'netbar_sidebar_recheck_width',
            '',
        );
    }
}

sub netbar_sidebar_schedule_width_recheck {
    # Do not keep postponing this correction while rapid redraw/layout signals
    # arrive. The old cancel-and-restart loop could starve the timer and leave
    # Irssi's initial 50/50 split on screen indefinitely.
    return if defined $netbar_sidebar_width_timer;
    $netbar_sidebar_width_attempts = 0;
    $netbar_sidebar_width_timer = Irssi::timeout_add_once(
        80,
        'netbar_sidebar_recheck_width',
        '',
    );
}

sub netbar_sidebar_enforce_width {
    my $sidebar = netbar_sidebar_window();
    my $content = netbar_sidebar_content_window();
    my $restore = netbar_sidebar_resize_restore_window($content);
    return 0 unless $sidebar && $restore;
    return 0 unless defined($sidebar->{refnum}) && defined($restore->{refnum});

    my $desired = netbar_sidebar_desired_width();
    my $actual = int($sidebar->{width} // 0);

    # Irssi balances vertical splits again when the terminal width changes.
    # Reapply the configured width whenever that happens, while leaving height
    # completely automatic so increasing/decreasing terminal height immediately
    # changes the number of visible channel rows.
    if ($actual != $desired || $netbar_sidebar_last_forced_width != $desired) {
        $netbar_sidebar_layout_guard = 1;
        Irssi::command('^window goto ' . int($sidebar->{refnum}));
        Irssi::command('^window size -right ' . $desired);
        Irssi::command('^window goto ' . int($restore->{refnum}));
        $netbar_sidebar_layout_guard = 0;
        $netbar_sidebar_last_content_refnum = int($restore->{refnum});
        $netbar_sidebar_last_forced_width = $desired;
        $netbar_sidebar_last_signature = '';
        netbar_sidebar_schedule_width_recheck();
    }

    return 1;
}

sub netbar_sidebar_resize_watchdog {
    return if $netbar_sidebar_layout_guard;

    my $sidebar = netbar_sidebar_window();
    return unless $sidebar;

    my $width  = int($sidebar->{width}  // 0);
    my $height = int($sidebar->{height} // 0);
    my $dimensions_changed =
        $width != $netbar_sidebar_observed_width
        || $height != $netbar_sidebar_observed_height;

    $netbar_sidebar_observed_width  = $width;
    $netbar_sidebar_observed_height = $height;

    my $desired = netbar_sidebar_desired_width();
    netbar_sidebar_enforce_width() if $width != $desired;

    if ($dimensions_changed) {
        $netbar_sidebar_last_signature = '';
        netbar_schedule_refresh();
    }
}

sub netbar_sidebar_ensure_layout {
    my ($requested_content) = @_;
    return 0 if $netbar_sidebar_shutting_down;

    netbar_sidebar_disable_autostick();

    my $content = $requested_content;
    $content = undef if $content && netbar_sidebar_is_window($content);
    $content ||= netbar_sidebar_content_window();
    my $sidebar = netbar_sidebar_create_window();
    return 0 unless $content && $sidebar;
    return 0 unless defined($content->{refnum}) && defined($sidebar->{refnum});

    if (!$netbar_sidebar_layout_ready) {
        my $content_ref = int($content->{refnum});
        my $sidebar_ref = int($sidebar->{refnum});
        my $width = netbar_sidebar_desired_width();

        $netbar_sidebar_layout_guard = 1;

        # Remove every old split association before rebuilding.  This matters
        # especially after /PART: a surviving channel can still remember the
        # left split even though the sidebar is visible there at that moment.
        netbar_sidebar_unstick_content_windows();
        netbar_sidebar_hide_other_splits($sidebar, $content);

        # Replace the remaining split with the permanent sidebar and create one
        # non-sticky conversation split on its right.  Only the sidebar stays
        # sticky, so /WINDOW GOTO and Alt+arrow can never replace it.
        Irssi::command('^window goto ' . $sidebar_ref);
        Irssi::command('^window stick on');
        Irssi::command('^window show -right ' . $content_ref);
        Irssi::command('^window goto ' . $sidebar_ref);
        Irssi::command('^window size -right ' . $width);
        Irssi::command('^window goto ' . $content_ref);
        netbar_sidebar_unstick_window($content);

        $netbar_sidebar_last_content_refnum = $content_ref;
        $netbar_sidebar_layout_ready = 1;
        $netbar_sidebar_layout_guard = 0;
        $netbar_sidebar_last_signature = '';
    }

    netbar_sidebar_apply_black_theme($sidebar, $content);
    netbar_sidebar_enforce_width();
    netbar_sidebar_apply_chrome();
    return 1;
}

sub netbar_sidebar_normalise_layout {
    $netbar_sidebar_normalise_timer = undef;
    return if $netbar_sidebar_shutting_down;

    my $sidebar = netbar_sidebar_window();
    my $content = netbar_sidebar_content_window();
    return unless $sidebar && $content;
    return unless defined($sidebar->{refnum}) && defined($content->{refnum});

    netbar_sidebar_disable_autostick();
    $netbar_sidebar_layout_guard = 1;
    netbar_sidebar_unstick_content_windows();
    netbar_sidebar_hide_other_splits($sidebar, $content);
    Irssi::command('^window goto ' . int($content->{refnum}));
    $netbar_sidebar_layout_guard = 0;
    $netbar_sidebar_last_content_refnum = int($content->{refnum});
    $netbar_sidebar_last_signature = '';
    netbar_schedule_refresh();
}

sub netbar_sidebar_schedule_normalise {
    return if $netbar_sidebar_shutting_down;
    return if defined $netbar_sidebar_normalise_timer;
    $netbar_sidebar_normalise_timer = Irssi::timeout_add_once(
        80,
        'netbar_sidebar_normalise_layout',
        '',
    );
}

sub netbar_sidebar_recovery_content_window {
    my ($wanted_network) = @_;

    my $active = Irssi::active_win();

    my @candidates;
    for my $channel (Irssi::channels()) {
        next unless $channel && $channel->{joined} && $channel->{server};
        my $network = netbar_network_name_for_server($channel->{server});
        my $window = eval { $channel->window() };
        next unless $window && defined($window->{refnum});
        next if netbar_sidebar_is_window($window);
        push @candidates, {
            window  => $window,
            network => $network,
            refnum  => int($window->{refnum}),
        };
    }

    @candidates = sort {
        my $a_same = length($wanted_network)
            && lc($a->{network}) eq lc($wanted_network) ? 0 : 1;
        my $b_same = length($wanted_network)
            && lc($b->{network}) eq lc($wanted_network) ? 0 : 1;
        $a_same <=> $b_same || $a->{refnum} <=> $b->{refnum};
    } @candidates;
    # After closing the active channel, prefer another joined channel from the
    # same network over the temporary Status/sidebar focus selected by Irssi.
    if (@candidates && length($wanted_network)
        && lc($candidates[0]->{network}) eq lc($wanted_network)) {
        return $candidates[0]->{window};
    }

    return $active if $active && !netbar_sidebar_is_window($active);
    return $candidates[0]->{window} if @candidates;

    my $status = eval { Irssi::window_find_refnum(1) };
    return $status if $status && !netbar_sidebar_is_window($status);

    for my $window (Irssi::windows()) {
        next unless $window && !netbar_sidebar_is_window($window);
        return $window;
    }
    return undef;
}

sub netbar_sidebar_recover_layout {
    $netbar_sidebar_recovery_timer = undef;
    return if $netbar_sidebar_shutting_down;

    my $content = netbar_sidebar_recovery_content_window(
        $netbar_sidebar_recovery_network,
    );
    return unless $content && defined($content->{refnum});

    my $content_ref = int($content->{refnum});

    # Do not GOTO the fallback before rebuilding.  If its old sticky group is
    # the left split, doing so replaces the sidebar and recreates the exact
    # two-conversation-pane bug this recovery is meant to prevent.
    $netbar_sidebar_last_content_refnum = $content_ref;
    $netbar_sidebar_layout_ready = 0;
    $netbar_sidebar_last_forced_width = -1;
    $netbar_sidebar_last_signature = '';
    netbar_sidebar_forget_buffer();

    netbar_sidebar_ensure_layout($content);
    $netbar_sidebar_recovery_network = '';
    netbar_schedule_refresh();
}

sub netbar_sidebar_schedule_recovery {
    return if $netbar_sidebar_shutting_down;
    return if defined $netbar_sidebar_recovery_timer;
    $netbar_sidebar_recovery_timer = Irssi::timeout_add_once(
        30,
        'netbar_sidebar_recover_layout',
        '',
    );
}

sub netbar_sidebar_restore_focus {
    $netbar_sidebar_focus_timer = undef;
    my $content = netbar_sidebar_content_window();
    return unless $content && defined($content->{refnum});

    $netbar_sidebar_layout_guard = 1;
    Irssi::command('^window goto ' . int($content->{refnum}));
    $netbar_sidebar_layout_guard = 0;
}

sub netbar_sidebar_schedule_focus_restore {
    return if defined $netbar_sidebar_focus_timer;
    $netbar_sidebar_focus_timer = Irssi::timeout_add_once(
        10,
        'netbar_sidebar_restore_focus',
        '',
    );
}

sub netbar_sidebar_teardown {
    undef $netbar_mouse_drag;
    my ($close_window) = @_;
    my @sidebars = grep { netbar_sidebar_is_window($_) } Irssi::windows();
    my $content = netbar_sidebar_content_window();

    $netbar_sidebar_layout_guard = 1;
    for my $sidebar (@sidebars) {
        next unless $sidebar && defined($sidebar->{refnum});
        my $sidebar_ref = int($sidebar->{refnum});
        Irssi::command('^window goto ' . $sidebar_ref);
        Irssi::command('^window theme -delete');
        if ($close_window) {
            Irssi::command('^window immortal off');
            Irssi::command('^window close ' . $sidebar_ref);
        }
        else {
            Irssi::command('^window hide ' . $sidebar_ref);
        }
    }
    if ($content && defined($content->{refnum})) {
        Irssi::command('^window goto ' . int($content->{refnum}));
    }
    $netbar_sidebar_layout_guard = 0;

    $netbar_sidebar_layout_ready = 0;
    $netbar_sidebar_created_by_script = 0 if $close_window;
    $netbar_sidebar_last_signature = '';
    netbar_sidebar_forget_buffer();
    $netbar_sidebar_last_forced_width = -1;
    $netbar_sidebar_auto_width = 0 if $close_window;
    $netbar_sidebar_theme_applied_refnum = 0;
    $netbar_sidebar_chrome_ready = 0;
    $netbar_sidebar_observed_width = -1;
    $netbar_sidebar_observed_height = -1;
    @netbar_sidebar_mouse_rows = ();
    $netbar_sidebar_mouse_width = -1;
    $netbar_sidebar_mouse_height = -1;
    netbar_sidebar_cancel_width_timer();
    if (defined $netbar_sidebar_normalise_timer) {
        Irssi::timeout_remove($netbar_sidebar_normalise_timer);
        $netbar_sidebar_normalise_timer = undef;
    }
    if (defined $netbar_sidebar_recovery_timer) {
        Irssi::timeout_remove($netbar_sidebar_recovery_timer);
        $netbar_sidebar_recovery_timer = undef;
    }
    netbar_sidebar_restore_chrome();
    netbar_sidebar_restore_autostick();
    netbar_mouse_sync_tracking() if defined &netbar_mouse_sync_tracking;
}

sub netbar_sidebar_overflow_row {
    my ($direction, $count) = @_;
    $count = int($count // 0);
    my $plain = ' ' . $direction . ' ' . $count;
    my $text = '%0%W %K' . $direction . ' %w' . $count;
    return {
        key          => '__sidebar_overflow__',
        text         => $text,
        plain_length => matrix_text_width($plain),
        mouse_kind   => 'overflow',
        mouse_direction => $direction eq '^' ? 'up' : 'down',
        mouse_x_start   => 0,
    };
}

my $netbar_sidebar_reveal_key = '';
my $netbar_sidebar_manual_start;
my $netbar_sidebar_view_start = 0;
my $netbar_sidebar_view_total = 0;
my $netbar_sidebar_view_capacity = 0;
my $netbar_sidebar_view_context = '';

sub netbar_sidebar_reveal_pm {
    my ($server, $nick) = @_;
    $netbar_sidebar_reveal_key = lc(netbar_server_tag($server)) . "\0query\0" . matrix_irc_casefold($server, $nick);
    undef $netbar_sidebar_manual_start;
}

sub netbar_sidebar_follow_active {
    $netbar_sidebar_reveal_key = '';
    undef $netbar_sidebar_manual_start;
    netbar_schedule_refresh();
}

sub netbar_sidebar_scroll_rows {
    my ($direction) = @_;
    return if $netbar_sidebar_view_total <= $netbar_sidebar_view_capacity;
    my $max = $netbar_sidebar_view_total - ($netbar_sidebar_view_capacity - 3);
    $max = $netbar_sidebar_view_total - 1 if $max >= $netbar_sidebar_view_total;
    my $start = $netbar_sidebar_view_start + ($direction < 0 ? -3 : 3);
    $start = 0 if $start < 0;
    $start = $max if $start > $max;
    $netbar_sidebar_reveal_key = '';
    $netbar_sidebar_manual_start = $start;
    netbar_schedule_refresh();
}

sub netbar_sidebar_visible_rows {
    my ($rows_ref, $capacity) = @_;
    my @rows = @$rows_ref;
    $netbar_sidebar_view_total = scalar @rows;
    $netbar_sidebar_view_capacity = $capacity;
    my $context = matrix_active_prompt_signature();
    if ($context ne $netbar_sidebar_view_context) {
        $netbar_sidebar_view_context = $context;
        $netbar_sidebar_reveal_key = '';
        undef $netbar_sidebar_manual_start;
    }
    if (@rows <= $capacity) {
        $netbar_sidebar_view_start = 0;
        undef $netbar_sidebar_manual_start;
        return @rows;
    }
    return () if $capacity <= 0;
    my $focus = 0;
    for my $index (0 .. $#rows) {
        if ($rows[$index]->{selected} || ($rows[$index]->{text} // '') =~ /%B>/) {
            $focus = $index;
            last;
        }
    }
    if (length $netbar_sidebar_reveal_key) {
        for my $index (0 .. $#rows) {
            if (($rows[$index]->{key} // '') eq $netbar_sidebar_reveal_key) {
                $focus = $index;
                last;
            }
        }
    }
    return ($rows[$focus]) if $capacity < 4;
    my $slots = $capacity - 3;
    my $start = defined($netbar_sidebar_manual_start)
        ? $netbar_sidebar_manual_start : $focus - int($slots / 2);
    $start = 0 if $start < 0;
    $start = @rows - $slots if $start > @rows - $slots;
    $netbar_sidebar_view_start = $start;
    my @visible;
    push @visible, netbar_sidebar_overflow_row('^', $start) if $start > 0;

    # A clipped network block retains its own header. A plain nr/nick must
    # never appear beneath the wrong network merely because the panel scrolled.
    my $network = $rows[$start]->{section_network};
    if (defined($network) && ($rows[$start]->{mouse_kind} // '') ne 'network') {
        my ($header) = grep {
            ($_->{mouse_kind} // '') eq 'network'
                && ($_->{section_network} // '') eq $network
        } @rows;
        push @visible, { %$header, key => '__sticky__' . $header->{key} } if $header;
    }
    my $available = $capacity - @visible;
    $available-- if @rows - $start > $available;
    my $end = $start + $available - 1;
    $end = $#rows if $end > $#rows;
    push @visible, @rows[$start .. $end];
    push @visible, netbar_sidebar_overflow_row('v', @rows - $end - 1)
        if $end < $#rows;
    return @visible;
}

sub netbar_sidebar_row_text {
    my ($row, $width) = @_;
    return '%0%W' unless $row;

    my $inner_width = $width - 1; # final cell is the vertical divider
    $inner_width = 1 if $inner_width < 1;
    my $plain_length = int($row->{plain_length} // 0);
    my $text = $row->{text} // '%0%W';

    # A sidebar row must always occupy exactly one terminal row. In particular,
    # an AWAY reason can be longer than the width used to build the click map.
    # If such text wraps physically, every channel below it gets a wrong mouse
    # Y coordinate. Clip the formatted string without cutting Irssi %X colour
    # controls, then pad it to the divider as usual.
    if ($plain_length > $inner_width) {
        $text = netbar_format_truncate_cells($text, $inner_width);
        $plain_length = $inner_width;
    }
    my $padding = $inner_width - $plain_length;
    $padding = 0 if $padding < 0;

    return $text . '%0%W'
        . (' ' x $padding)
        . '%0%k│%n';
}

# Truncate a sidebar string by displayed terminal cells while retaining Irssi's
# two-character formatting controls. ``%%`` is a visible literal percent and
# therefore consumes one cell; the remaining %X controls consume no cells.
sub netbar_format_truncate_cells {
    my ($formatted, $cells) = @_;
    $formatted = matrix_decode_valid_utf8($formatted // '');
    $cells = int($cells // 0);
    return '' if $cells <= 0;

    my $result = '';
    my $used = 0;
    my @characters = split //, $formatted;
    for (my $index = 0; $index < @characters; $index++) {
        my $character = $characters[$index];
        if ($character eq '%' && $index + 1 < @characters) {
            my $control = $characters[++$index];
            if ($control eq '%') {
                last if $used >= $cells;
                $used++;
            }
            $result .= '%' . $control;
            next;
        }

        my $width = matrix_text_width($character);
        $width = 0 if $width < 0;
        last if $used + $width > $cells;
        $result .= $character;
        $used += $width;
    }
    return $result;
}

# Resolve lines from the current view, bounded by the expected panel size.
# Returned wrappers are temporary and are never stored in module state.
sub netbar_sidebar_live_lines {
    my ($view, $height) = @_;
    return unless $view && $height > 0;
    my @lines;
    my $line = $view->get_lines();
    while ($line && @lines <= $height + 1) {
        push @lines, $line;
        $line = $line->next();
    }
    return unless @lines == $height + 1 && !$line;
    return \@lines;
}

sub netbar_sidebar_buffer_ready {
    my ($width, $height) = @_;
    return 0 unless $netbar_sidebar_buffer_width == $width;
    return 0 unless $netbar_sidebar_buffer_height == $height;
    return 0 unless @netbar_sidebar_buffer_text == $height;
    return 0 unless @netbar_sidebar_buffer_raw == $height + 1;
    my $sidebar = netbar_sidebar_window();
    return 0 unless $sidebar;
    my $lines = netbar_sidebar_live_lines($sidebar->view(), $height);
    return 0 unless $lines;
    for my $i (0 .. $#$lines) {
        return 0 unless $lines->[$i]->get_text(Irssi::COLORING_RAW())
            eq $netbar_sidebar_buffer_raw[$i];
    }
    return 1;
}

sub netbar_sidebar_buffer_watch {
    return if $matrix_unloading || $netbar_sidebar_shutting_down;
    return unless Irssi::settings_get_bool('network_statusbars_enabled');
    return unless $netbar_sidebar_buffer_height > 0;
    netbar_schedule_refresh() unless netbar_sidebar_buffer_ready(
        $netbar_sidebar_buffer_width, $netbar_sidebar_buffer_height,
    );
}

sub netbar_sidebar_rebuild_buffer {
    my ($sidebar, $view, $texts_ref, $width, $height) = @_;
    return 0 unless $sidebar && $view;
    netbar_sidebar_forget_buffer();
    $view->remove_all_lines();
    # One off-screen anchor, followed by exactly one line per panel row.
    $sidebar->print('%0%W', Irssi::MSGLEVEL_NEVER());
    for my $text (@$texts_ref) {
        $sidebar->print($text, Irssi::MSGLEVEL_NEVER());
    }
    my $lines = netbar_sidebar_live_lines($view, $height);
    return 0 unless $lines;
    @netbar_sidebar_buffer_raw = map {
        $_->get_text(Irssi::COLORING_RAW())
    } @$lines;
    @netbar_sidebar_buffer_text = @$texts_ref;
    $netbar_sidebar_buffer_width = $width;
    $netbar_sidebar_buffer_height = $height;
    $view->redraw();
    return 1;
}

sub netbar_sidebar_patch_buffer {
    my ($sidebar, $view, $texts_ref) = @_;
    return 0 unless $sidebar && $view;
    my $height = scalar @$texts_ref;
    for my $index (0 .. $#$texts_ref) {
        my $new_text = $texts_ref->[$index];
        next if defined($netbar_sidebar_buffer_text[$index])
            && $netbar_sidebar_buffer_text[$index] eq $new_text;
        # Resolve immediately before each mutation. Never dereference a wrapper
        # after removing its line, or carry the previous row across that removal.
        {
            my $lines = netbar_sidebar_live_lines($view, $height);
            return 0 unless $lines;
            $view->remove_line($lines->[$index + 1]);
        }
        my $previous = $view->get_lines();
        for (1 .. $index) {
            return 0 unless $previous;
            $previous = $previous->next();
        }
        return 0 unless $previous;
        $sidebar->print_after($previous, Irssi::MSGLEVEL_NEVER(), $new_text);
        undef $previous;
        $netbar_sidebar_buffer_text[$index] = $new_text;
    }
    my $lines = netbar_sidebar_live_lines($view, $height);
    return 0 unless $lines;
    @netbar_sidebar_buffer_raw = map {
        $_->get_text(Irssi::COLORING_RAW())
    } @$lines;
    $view->redraw();
    return 1;
}

sub netbar_sidebar_render {
    return unless netbar_sidebar_ensure_layout();

    my $sidebar = netbar_sidebar_window();
    return unless $sidebar && defined($sidebar->{refnum});

    my $width = int($sidebar->{width} // 0);
    my $height = int($sidebar->{height} // 0);
    $width = netbar_sidebar_desired_width() if $width < 10;
    $width = netbar_sidebar_desired_width() if $width < 20;
    $height = 30 if $height < 6;

    my $layout_width = $width - 1; # reserve the divider column
    $layout_width = 20 if $layout_width < 20;
    my $previous_width_override = $netbar_width_override;
    $netbar_width_override = $layout_width;
    my @rows = netbar_build_layout_rows();
    $netbar_width_override = $previous_width_override;

    # The sidebar uses its full height for networks, channels and private chats.
    my $content_capacity = $height;
    $content_capacity = 1 if $content_capacity < 1;
    my @visible = netbar_sidebar_visible_rows(\@rows, $content_capacity);

    my @screen = @visible;
    while (@screen < $height) {
        push @screen, {
            key          => '__sidebar_blank__',
            text         => '%0%W',
            plain_length => 0,
        };
    }
    $#screen = $height - 1 if @screen > $height;

    # Update hit testing even when the rendered text did not change. A server
    # reconnect may replace Irssi objects while producing the same visible
    # rows; names and server tags below are intentionally resolved on click.
    @netbar_sidebar_mouse_rows = @screen;
    $netbar_sidebar_mouse_width = $width;
    $netbar_sidebar_mouse_height = $height;

    my $signature = join("\x1f",
        $width,
        $height,
        map { ($_->{text} // '') . "\x1e" . int($_->{plain_length} // 0) }
            @screen,
    );
    my $buffer_ready = netbar_sidebar_buffer_ready($width, $height);
    return if $signature eq $netbar_sidebar_last_signature && $buffer_ready;
    $netbar_sidebar_last_signature = $signature;

    my @screen_text = map {
        netbar_sidebar_row_text($_, $width)
    } @screen;

    # Keep a persistent line for every screen row. Ordinary navigation now
    # replaces only rows whose text actually changed; a full buffer rebuild is
    # reserved for initialisation and real sidebar-size changes.
    my ($render_ok, $render_error);
    matrix_with_frozen_refresh(sub {
        $render_ok = eval {
            my $view = $sidebar->view();
            die "sidebar view unavailable\n" unless $view;

            if (!$buffer_ready) {
                die "sidebar buffer rebuild failed\n"
                    unless netbar_sidebar_rebuild_buffer(
                        $sidebar,
                        $view,
                        \@screen_text,
                        $width,
                        $height,
                    );
            }
            elsif (!netbar_sidebar_patch_buffer(
                $sidebar,
                $view,
                \@screen_text,
            )) {
                # If an external command altered the technical sidebar
                # scrollback, recover in this same frozen terminal frame.
                die "sidebar buffer recovery failed\n"
                    unless netbar_sidebar_rebuild_buffer(
                        $sidebar,
                        $view,
                        \@screen_text,
                        $width,
                        $height,
                    );
            }
            1;
        };
        $render_error = $@;
    });

    if (!$render_ok) {
        netbar_sidebar_forget_buffer();
        Irssi::print(
            'network_statusbars: sidebar render failed: ' . $render_error,
            Irssi::MSGLEVEL_CLIENTERROR(),
        );
    }
}


sub netbar_status_network_is_active {
    my ($network) = @_;
    return 0 unless defined($network) && length($network);
    return 0 unless netbar_status_window_is_active();

    my $active_network = netbar_active_network_name();
    return length($active_network) && lc($active_network) eq lc($network)
        ? 1
        : 0;
}


sub netbar_signal_gui_window_resized {
    undef $netbar_mouse_chat_click;
    undef $netbar_mouse_drag;
    return if $netbar_sidebar_layout_guard;

    # Re-read both width and height after Irssi has finished its own balancing.
    # The one-shot correction plus the one-second watchdog keep the sidebar
    # fixed while the conversation split absorbs all terminal-width changes.
    $netbar_sidebar_last_signature = '';
    netbar_sidebar_schedule_width_recheck();
    netbar_schedule_refresh();
    netbar_matrix_input_cursor_schedule_apply(650)
        if defined &netbar_matrix_input_cursor_schedule_apply;
}


sub netbar_signal_window_destroyed {
    undef $netbar_mouse_chat_click;
    my ($window) = @_;
    return if $netbar_sidebar_shutting_down;

    my $destroyed_ref = $window && defined($window->{refnum})
        ? int($window->{refnum})
        : 0;
    my $active = Irssi::active_win();
    my $sidebar_took_focus = $active && netbar_sidebar_is_window($active);
    return unless $sidebar_took_focus
        || ($destroyed_ref
            && $destroyed_ref == $netbar_sidebar_last_content_refnum);

    # When the right-hand window closes, the sidebar briefly receives focus.
    # Cancel its ordinary "return to Status" timer; recovery below will select
    # another joined channel from the same network and rebuild the two panes.
    if (defined $netbar_sidebar_focus_timer) {
        Irssi::timeout_remove($netbar_sidebar_focus_timer);
        $netbar_sidebar_focus_timer = undef;
    }
    netbar_sidebar_schedule_recovery();
}


sub netbar_status_identity_data {
    my ($network) = @_;
    my $server = netbar_server_for_network($network);
    return ('', '') unless $server;

    my $nick_plain = netbar_plain_dynamic_text($server->{nick} // '');
    my $mode_plain = netbar_mode_plain($server->{usermode} // '');
    my $plain = '[' . $nick_plain;
    $plain .= '(' . $mode_plain . ')' if length $mode_plain;
    $plain .= ']';

    return ($plain, netbar_format_dynamic_text($plain));
}


sub netbar_plain_dynamic_text {
    my ($text) = @_;
    $text = matrix_terminal_plain_text($text);
    $text =~ tr/{}/()/;
    return $text;
}

sub netbar_format_dynamic_text {
    my ($text) = @_;
    $text = netbar_plain_dynamic_text($text);
    $text =~ s/%/%%/g;
    return $text;
}

sub netbar_natural_network_sort {
    my ($left, $right) = @_;

    my @left_parts  = split /(\d+)/, lc $left;
    my @right_parts = split /(\d+)/, lc $right;

    while (@left_parts && @right_parts) {
        my $a = shift @left_parts;
        my $b = shift @right_parts;

        my $cmp;
        if ($a =~ /^\d+$/ && $b =~ /^\d+$/) {
            $cmp = $a <=> $b;
        }
        else {
            $cmp = $a cmp $b;
        }
        return $cmp if $cmp;
    }

    return @left_parts <=> @right_parts || lc($left) cmp lc($right);
}

sub netbar_preferred_networks {
    my @networks = @_;
    my $order = Irssi::settings_get_str('network_statusbars_order') // '';

    my @preferred = grep { length } split /[\s,]+/, $order;
    my %rank;
    for my $index (0 .. $#preferred) {
        $rank{lc $preferred[$index]} = $index;
    }

    return sort {
        my $a_rank = exists $rank{lc $a} ? $rank{lc $a} : 1_000_000;
        my $b_rank = exists $rank{lc $b} ? $rank{lc $b} : 1_000_000;
        $a_rank <=> $b_rank || netbar_natural_network_sort($a, $b);
    } @networks;
}

sub netbar_network_name_for_server {
    my ($server) = @_;
    return '' unless $server;
    return $server->{tag} || $server->{chatnet} || '';
}

sub netbar_server_tag {
    my ($server) = @_;
    return '' unless $server;
    return defined($server->{tag}) ? $server->{tag} : '';
}

sub netbar_active_network_name {
    my $window = Irssi::active_win();
    return '' unless $window;

    my $item = $window->{active};
    my $server = $item && $item->{server} ? $item->{server} : undef;

    # In the Status window there is no channel/query item. Use the window's
    # selected server so Ctrl-X still makes IRCnet/IRCnet2 bright/dark in real
    # time while the user is checking server-specific commands there.
    $server = $window->{active_server}
        if !$server && $window->{active_server};
    $server = eval { Irssi::active_server() }
        if !$server;

    return netbar_network_name_for_server($server) if $server;
    return '';
}

sub netbar_network_is_active {
    my ($network) = @_;
    return 0 unless defined($network) && length($network);

    my $active_network = netbar_active_network_name();
    return length($active_network) && lc($active_network) eq lc($network) ? 1 : 0;
}

sub netbar_status_window_is_active {
    my $window = Irssi::active_win();
    return 0 unless $window && defined($window->{refnum});
    return int($window->{refnum}) == 1 ? 1 : 0;
}

sub netbar_status_attention_key_for_context {
    my ($context) = @_;
    my $server;

    if (ref($context)) {
        $server = $context;
    }
    elsif (defined($context) && length($context)) {
        $server = Irssi::server_find_tag($context);
        if (!$server) {
            for my $candidate (Irssi::servers()) {
                next unless $candidate;
                my $network = netbar_network_name_for_server($candidate);
                if ((defined($candidate->{tag}) && lc($candidate->{tag}) eq lc($context))
                    || (length($network) && lc($network) eq lc($context))) {
                    $server = $candidate;
                    last;
                }
            }
        }
        return lc($context) unless $server;
    }
    else {
        my $window = Irssi::active_win();
        my $item = $window ? $window->{active} : undef;
        $server = $item && $item->{server} ? $item->{server} : undef;
        $server = $window->{active_server}
            if !$server && $window && $window->{active_server};
        $server = eval { Irssi::active_server() } unless $server;
    }

    my $network = netbar_network_name_for_server($server);
    return length($network) ? lc($network) : '';
}

sub netbar_clear_status_attention {
    my ($context) = @_;
    return unless keys %status_attention_by_network;

    my $key = netbar_status_attention_key_for_context($context);
    return unless length($key) && exists $status_attention_by_network{$key};
    delete $status_attention_by_network{$key};
    netbar_schedule_refresh() if defined &netbar_schedule_refresh;
}

# Mark one network as having an unread line in window 1. The context may be an
# Irssi server object or a server tag/chatnet string (used by PRV-GUARD timers).
sub netbar_mark_status_attention {
    my ($context) = @_;

    my $server;
    if (ref($context)) {
        $server = $context;
    }
    elsif (defined($context) && length($context)) {
        $server = Irssi::server_find_tag($context);
        if (!$server) {
            for my $candidate (Irssi::servers()) {
                next unless $candidate;
                my $network = netbar_network_name_for_server($candidate);
                if ((defined($candidate->{tag}) && lc($candidate->{tag}) eq lc($context))
                    || (length($network) && lc($network) eq lc($context))) {
                    $server = $candidate;
                    last;
                }
            }
        }
    }
    return unless $server;

    my $network = netbar_network_name_for_server($server);
    return unless length $network;

    # Reading Status for network A acknowledges only A. A server-level line
    # arriving for network B must still light B's Status label.
    if (netbar_status_window_is_active()) {
        my $active_key = netbar_status_attention_key_for_context();
        return if length($active_key) && $active_key eq lc($network);
    }

    $status_attention_by_network{lc $network} = 1;
    netbar_schedule_refresh() if defined &netbar_schedule_refresh;
}

sub netbar_signal_status_print {
    my ($dest, $text, $stripped) = @_;
    return unless $dest;
    return if $status_attention_suppression_depth > 0;

    my $window = $dest->{window};
    return unless $window && defined($window->{refnum});
    return unless int($window->{refnum}) == 1;

    my $server = $dest->{server};
    return unless $server;

    my $network = netbar_network_name_for_server($server);
    if (length($network)) {
        my $key = lc($network);
        my $quiet_until = $status_attention_quiet_until_by_network{$key} || 0;
        if ($quiet_until > time()) {
            return;
        }
        delete $status_attention_quiet_until_by_network{$key}
            if $quiet_until;
    }

    netbar_mark_status_attention($server);
}

sub netbar_quiet_status_attention_for_server {
    my ($server, $seconds) = @_;
    return unless $server;

    my $network = netbar_network_name_for_server($server);
    return unless length($network);

    $seconds = 3 unless defined($seconds) && $seconds > 0;
    $status_attention_quiet_until_by_network{lc $network} = time() + $seconds;
}

sub netbar_channel_key {
    my ($server, $channel_name) = @_;
    return '' unless $server && defined $channel_name && length $channel_name;

    my $network = netbar_network_name_for_server($server);
    return '' unless length $network;

    return lc($network) . "\0" . matrix_irc_casefold($server, $channel_name);
}

sub netbar_muted_channels_load {
    my $raw = Irssi::settings_get_str('network_statusbars_muted_channels');
    $raw = '' unless defined $raw;

    my %loaded;
    for my $token (split /,/, $raw) {
        next unless $token =~ /\A[0-9a-fA-F]+\z/ && length($token) % 2 == 0;
        my $key = pack('H*', $token);
        next unless $key =~ /\0/;
        my ($tag, $channel) = split /\0/, $key, 2;
        # CASEMAPPING may not be known until connect. Keep the saved spelling
        # until a live server can migrate it, especially for ASCII networks.
        $loaded{lc($tag) . "\0" . $channel} = 1;
    }
    %muted_channels = %loaded;
}

sub netbar_muted_channels_save {
    my $raw = join(',', map { unpack('H*', $_) } sort keys %muted_channels);
    Irssi::settings_set_str('network_statusbars_muted_channels', $raw);

    # Do not write the whole Irssi configuration as a side effect of /mute.
    # Users who explicitly want immediate persistence can enable this setting.
    Irssi::command('save')
        if Irssi::settings_get_bool('network_statusbars_mute_autosave');
}

sub netbar_channel_is_muted {
    my ($server, $channel_name) = @_;
    my $key = netbar_channel_key($server, $channel_name);
    return 0 unless length $key;
    return 1 if $muted_channels{$key};
    my $tag = lc(netbar_server_tag($server));
    my $chatnet = lc($server->{chatnet} // '');
    # Old releases keyed mutes by chatnet. Migrate a different chatnet label
    # only when it resolves to exactly one connection and is not another tag.
    my @same_chatnet = grep { lc($_->{chatnet} // '') eq $chatnet } Irssi::servers();
    my $legacy_unique = length($chatnet) && @same_chatnet == 1
        && !Irssi::server_find_tag($chatnet);
    for my $old_key (keys %muted_channels) {
        my ($old_tag, $old_channel) = split /\0/, $old_key, 2;
        next unless $old_tag eq $tag || ($legacy_unique && $old_tag eq $chatnet);
        next unless matrix_irc_casefold($server, $old_channel)
            eq matrix_irc_casefold($server, $channel_name);
        delete $muted_channels{$old_key};
        $muted_channels{$key} = 1;
        return 1;
    }
    return 0;
}

sub netbar_channel_is_current {
    my ($channel) = @_;
    return 0 unless $channel;

    my $window = eval { $channel->window() };
    my $active_window = Irssi::active_win();
    return 0 unless $window && $active_window;
    return 0 unless defined $window->{refnum} && defined $active_window->{refnum};
    return 0 unless int($window->{refnum}) == int($active_window->{refnum});

    return 0 if matrix_window_scrolled($window);
    return eval { $channel->is_active() } ? 1 : 0;
}

sub netbar_clear_channel_activity {
    my ($channel) = @_;
    return unless $channel && $channel->{server} && defined $channel->{name};

    my $key = netbar_channel_key($channel->{server}, $channel->{name});
    if (length $key) {
        delete $message_activity{$key};
        delete $nick_attention{$key};
        delete $channel_unread_count{$key};
        matrix_mentions_clear($key);
    }
}

sub netbar_clear_current_channel_activity {
    return if $matrix_mention_jump;
    my $window = Irssi::active_win();
    return unless $window;
    return if matrix_window_scrolled($window);

    my $item = $window->{active};
    return unless $item && $item->{server} && defined $item->{name};

    my $type = uc($item->{type} // '');
    return unless $type eq 'CHANNEL' || ref($item) =~ /Channel/i;

    netbar_clear_channel_activity($item);
}

sub netbar_find_message_channel {
    my ($server, $target) = @_;
    return undef unless $server && defined $target && length $target;

    my $channel = eval { $server->channel_find($target) };
    return $channel if $channel;

    # Some IRCds include a channel-status prefix in the target, e.g. @#chan.
    my $stripped = $target;
    $stripped =~ s/^[~@%+]//;
    return undef if $stripped eq $target;

    return eval { $server->channel_find($stripped) };
}

sub netbar_message_mentions_own_nick {
    my ($server, $message) = @_;
    return 0 unless $server && defined $message;

    my $nick = defined($server->{nick}) ? $server->{nick} : '';
    return 0 unless length $nick;

    # IRC nickname characters are treated as part of a nick. This avoids
    # colouring the number for a longer word which only contains the nick.
    my $quoted = quotemeta(matrix_irc_casefold($server, $nick));
    $message = matrix_irc_casefold($server, $message);
    return $message =~
        /(?<![A-Za-z0-9_\-\[\]\\`^{}|~])$quoted(?![A-Za-z0-9_\-\[\]\\`^{}|~])/
        ? 1 : 0;
}

sub netbar_away_session_key {
    my ($server) = @_;
    my $tag = netbar_server_tag($server);
    return length($tag) ? lc($tag) : '';
}

sub netbar_away_server_is_active {
    my ($server) = @_;
    return ($server && $server->{usermode_away}) ? 1 : 0;
}

# Preserve AUTO-AWAY ownership and the in-memory mention journal only across a
# short /SCRIPT RELOAD in this same Irssi process. The server object identity,
# current AWAY state and reason are checked before ownership is restored, so a
# later manual AWAY cannot be mistaken for one created by MATRIX.
sub netbar_capture_reload_state {
    my %sessions;

    for my $server (netbar_away_connected_servers()) {
        my $key = netbar_away_session_key($server);
        next unless length $key;
        my $tag = netbar_server_tag($server);
        next unless exists($auto_away_servers{$key})
            || exists($away_mention_sessions{$key})
            || @{$oper_who_wire_queue{$tag} || []}
            || $oper_who_wire_desynced{$tag};

        $sessions{$key} = {
            server_identity => matrix_connection_identity($server),
            away_active     => netbar_away_server_is_active($server) ? 1 : 0,
            away_reason     => netbar_away_reason_for_server($server),
            auto_present    => exists($auto_away_servers{$key}) ? 1 : 0,
            auto_owned_at   => $auto_away_servers{$key},
            mention_state   => $away_mention_sessions{$key},
            # Data-only tombstones: late replies must not enter the next
            # instance's collector. No callback or timer crosses unload.
            who_queue       => [map { +{%$_} } @{$oper_who_wire_queue{$tag} || []}],
            who_desynced    => $oper_who_wire_desynced{$tag} ? 1 : 0,
        };
    }

    $Irssi::MATRIX_NETBAR_RELOAD_STATE = keys(%sessions) ? {
        format      => 1,
        captured_at => time(),
        sessions    => \%sessions,
    } : undef;
}

sub netbar_restore_reload_state {
    my $snapshot = $Irssi::MATRIX_NETBAR_RELOAD_STATE;
    undef $Irssi::MATRIX_NETBAR_RELOAD_STATE;
    return 0 unless ref($snapshot) eq 'HASH'
        && int($snapshot->{format} // 0) == 1
        && ref($snapshot->{sessions}) eq 'HASH';

    # Normal reload is effectively immediate. Refuse stale state from an old
    # unload even if the same server object somehow remains alive.
    my $age = time() - ($snapshot->{captured_at} // 0);
    my $expired = $age < 0 || $age > 300;

    my $restored = 0;
    for my $server (netbar_away_connected_servers()) {
        my $key = netbar_away_session_key($server);
        next unless length($key) && exists($snapshot->{sessions}{$key});

        my $saved = $snapshot->{sessions}{$key};
        next unless ref($saved) eq 'HASH';
        next unless ($saved->{server_identity} // '') eq matrix_connection_identity($server);
        my $tag = netbar_server_tag($server);
        if ($saved->{who_desynced}
            || ($expired && @{$saved->{who_queue} || []})) {
            $oper_who_wire_desynced{$tag} = 1;
        }
        elsif (ref($saved->{who_queue}) eq 'ARRAY' && @{$saved->{who_queue}}) {
            $oper_who_wire_queue{$tag} = [map { +{%$_} } @{$saved->{who_queue}}];
        }
        next if $expired;
        next unless $saved->{away_active}
            && netbar_away_server_is_active($server);

        my $saved_reason = $saved->{away_reason} // '';
        my $current_reason = netbar_away_reason_for_server($server);
        my $same_reason = $saved_reason eq $current_reason;

        if ($saved->{auto_present} && $same_reason) {
            $auto_away_servers{$key} = $saved->{auto_owned_at} || time();
        }
        if (ref($saved->{mention_state}) eq 'HASH') {
            $away_mention_sessions{$key} = $saved->{mention_state};
        }
        $restored++;
    }
    return $restored;
}


sub netbar_away_clean_plain_text {
    my ($text) = @_;
    $text = '' unless defined $text;

    # Irssi can pass a valid UTF-8 IRC line as an unflagged byte string.  The
    # old C1 filter then removed continuation bytes in the 0x80..0x9f range,
    # corrupting Polish characters such as ś, ć, ż, ó and ł. Decode only when
    # the complete byte string is valid UTF-8; already-decoded strings remain
    # untouched, and malformed/legacy byte strings retain the defensive path.
    if (!utf8::is_utf8($text) && length($text)) {
        my $bytes = $text;
        my $decoded = eval { decode('UTF-8', $bytes, FB_CROAK) };
        $text = $decoded if defined $decoded && !$@;
    }

    # Never allow IRC/terminal controls or line breaks from a remote message or
    # an AWAY reason to enter NOTICE commands or the local Status report. Once a
    # valid UTF-8 byte string has been decoded, this removes Unicode C0/C1 code
    # points rather than bytes belonging to a multibyte Polish character.
    $text =~ s/[\x00-\x1f\x7f-\x9f]//g;
    $text =~ s/\s+/ /g;
    $text =~ s/^\s+|\s+$//g;
    return $text;
}

sub netbar_away_status_text {
    my ($text) = @_;
    $text = netbar_away_clean_plain_text($text);
    $text =~ s/%/%%/g;
    return $text;
}

sub netbar_away_reason_for_server {
    my ($server) = @_;
    return '' unless $server;
    return netbar_away_clean_plain_text($server->{away_reason} // '');
}

sub netbar_away_session_for_server {
    my ($server, $create) = @_;
    return undef unless $server;

    my $key = netbar_away_session_key($server);
    return undef unless length $key;

    if ($create && !exists $away_mention_sessions{$key}) {
        $away_mention_sessions{$key} = {
            tag                 => netbar_server_tag($server),
            network             => netbar_network_name_for_server($server),
            started_at          => time(),
            reason              => netbar_away_reason_for_server($server),
            entries             => [],
            senders             => {},
            notice_times        => [],
            overflow_count      => 0,
            notices_suppressed  => 0,
            mass_active         => 0,
            mass_started_at     => 0,
            mass_recent_senders => {},
            mass_unique_seen    => {},
            mass_unique_count   => 0,
            mass_message_count  => 0,
            mass_identity_overflow => 0,
            mass_channels       => {},
        };
    }

    my $state = $away_mention_sessions{$key};
    if ($state) {
        $state->{tag} = netbar_server_tag($server);
        $state->{network} = netbar_network_name_for_server($server);
        $state->{reason} = netbar_away_reason_for_server($server);
    }
    return $state;
}

sub netbar_away_sender_key {
    my ($nick, $server) = @_;
    return matrix_irc_casefold($server, $nick);
}

sub netbar_away_setting_seconds {
    my ($name, $fallback) = @_;
    my $milliseconds = Irssi::settings_get_time($name);
    return $milliseconds > 0 ? $milliseconds / 1000 : $fallback;
}

sub netbar_away_mark_sender_spam {
    my ($state, $sender_key, $sender) = @_;
    return unless $state && defined($sender_key) && length($sender_key) && $sender;
    return if $sender->{spam};

    $sender->{spam} = 1;
    $sender->{recent} = [];

    # A spammer must never have message contents disclosed in the return report,
    # including lines received before the threshold was crossed.
    my $entries = $state->{entries} || [];
    @$entries = grep {
        ($_->{sender_key} // '') ne $sender_key
    } @$entries;
}


sub netbar_away_prune_senders {
    my ($state, $now) = @_;
    return unless $state;
    $now = time() unless defined $now;

    my $ttl = netbar_away_setting_seconds(
        'network_statusbars_away_sender_ttl',
        21600,
    );
    my $senders = ($state->{senders} ||= {});
    for my $key (keys %$senders) {
        delete $senders->{$key}
            if ($senders->{$key}{last_at} // 0) < $now - $ttl;
    }
}

sub netbar_away_sender_limit {
    my $limit = Irssi::settings_get_int(
        'network_statusbars_away_sender_limit'
    );
    $limit = 256 if !defined($limit) || $limit < 16;
    $limit = 4096 if $limit > 4096;
    return $limit;
}

sub netbar_away_send_notice {
    my ($server, $state, $sender) = @_;
    return unless $server && $server->{connected} && $state && $sender;
    return unless Irssi::settings_get_bool(
        'network_statusbars_away_notice_enabled'
    );
    return if $state->{mass_active};
    return if $sender->{spam};

    my $nick = $sender->{nick} // '';
    return unless length($nick);
    return if $nick =~ /[\r\n\x00-\x1f\x7f-\x9f]/;
    return unless $nick =~ /\A[^\s,:]+\z/;
    return if eval { $server->ischannel($nick) };

    my $now = time();
    return if ($sender->{notice_until} // 0) > $now;

    my $burst_window = netbar_away_setting_seconds(
        'network_statusbars_away_notice_burst_window',
        60,
    );
    my $burst_limit = Irssi::settings_get_int(
        'network_statusbars_away_notice_burst_limit'
    );
    $burst_limit = 10 if !defined($burst_limit) || $burst_limit < 1;

    my $notice_times = ($state->{notice_times} ||= []);
    @$notice_times = grep { $_ >= $now - $burst_window } @$notice_times;
    if (@$notice_times >= $burst_limit) {
        $state->{notices_suppressed}++;
        return;
    }

    my $notice = "I'm currently away.";
    if (Irssi::settings_get_bool(
        'network_statusbars_away_notice_include_reason'
    )) {
        my $reason = netbar_away_reason_for_server($server);
        $reason = substr($reason, 0, 220) if length($reason) > 220;
        $reason =~ s/[.!?]+$//;
        $notice = "I'm currently away. Reason: $reason"
            if length($reason) && lc($reason) ne 'away';
    }
    $notice = netbar_away_clean_plain_text($notice);

    my $ok = eval {
        $server->send_raw('NOTICE ' . $nick . ' :' . $notice);
        1;
    };
    return unless $ok;

    push @$notice_times, $now;
    my $interval = netbar_away_setting_seconds(
        'network_statusbars_away_notice_interval',
        3600,
    );
    $sender->{notice_until} = $now + $interval;
}

sub netbar_away_activate_mass_spam {
    my ($state, $now) = @_;
    return unless $state;
    return if $state->{mass_active};

    my $recent = $state->{mass_recent_senders} || {};
    my %seen = map { $_ => 1 } keys %$recent;
    my %channels;
    my $messages = 0;

    for my $item (values %$recent) {
        next unless $item;
        $messages += int($item->{count} // 0);
        $channels{$_} = 1 for keys %{ $item->{channels} || {} };
    }

    $state->{mass_active}        = 1;
    $state->{mass_started_at}    = $now;
    $state->{mass_unique_seen}   = \%seen;
    $state->{mass_unique_count}  = scalar keys %seen;
    $state->{mass_message_count} = $messages;
    $state->{mass_channels}      = \%channels;

    # Once a many-sender attack is detected, discard every previously stored
    # message body and per-sender report. From this point onward only aggregate
    # counters are retained, so thousands of nicks cannot create thousands of
    # report lines or outgoing NOTICE replies.
    $state->{entries} = [];
    $state->{senders} = {};
    $state->{notice_times} = [];
    $state->{overflow_count} = 0;
    $state->{notices_suppressed} = 0;
    $state->{mass_recent_senders} = {};
}

sub netbar_away_record_mass_spam_mention {
    my ($state, $sender_key, $channel_name) = @_;
    return unless $state && $state->{mass_active};

    $state->{mass_message_count}++;
    my $seen = ($state->{mass_unique_seen} ||= {});
    if (!$seen->{$sender_key}) {
        if (scalar(keys %$seen) < netbar_away_sender_limit()) {
            $seen->{$sender_key} = 1;
            $state->{mass_unique_count}++;
        }
        else {
            $state->{mass_identity_overflow}++;
        }
    }
    $state->{mass_channels}{$channel_name} = 1
        if defined($channel_name) && length($channel_name);
}

sub netbar_away_track_mass_spam_window {
    my ($state, $sender_key, $channel_name, $now) = @_;
    return 0 unless $state && defined($sender_key) && length($sender_key);

    my $window = netbar_away_setting_seconds(
        'network_statusbars_away_mass_window',
        10,
    );
    my $limit = Irssi::settings_get_int(
        'network_statusbars_away_mass_unique_limit'
    );
    $limit = 20 if !defined($limit) || $limit < 2;

    my $recent = ($state->{mass_recent_senders} ||= {});
    my $cutoff = $now - $window;
    for my $key (keys %$recent) {
        delete $recent->{$key}
            if ($recent->{$key}{last_at} // 0) < $cutoff;
    }

    my $item = ($recent->{$sender_key} ||= {
        last_at  => $now,
        count    => 0,
        channels => {},
    });
    $item->{last_at} = $now;
    $item->{count}++;
    $item->{channels}{$channel_name} = 1
        if defined($channel_name) && length($channel_name);

    if (scalar(keys %$recent) >= $limit) {
        netbar_away_activate_mass_spam($state, $now);
        return 1;
    }
    return 0;
}

sub netbar_away_record_mention {
    my ($server, $message, $nick, $channel_name) = @_;
    return unless netbar_away_server_is_active($server);
    return unless defined($nick) && length($nick);
    return unless defined($channel_name) && length($channel_name);

    my $own_nick = $server->{nick} // '';
    return if length($own_nick) && matrix_irc_casefold($server, $nick) eq matrix_irc_casefold($server, $own_nick);

    my $state = netbar_away_session_for_server($server, 1);
    return unless $state;

    my $now = time();
    my $sender_key = netbar_away_sender_key($nick, $server);
    return unless length $sender_key;

    if ($state->{mass_active}) {
        netbar_away_record_mass_spam_mention(
            $state, $sender_key, $channel_name,
        );
        return;
    }

    if (netbar_away_track_mass_spam_window(
        $state, $sender_key, $channel_name, $now,
    )) {
        return;
    }

    netbar_away_prune_senders($state, $now);
    my $senders = ($state->{senders} ||= {});
    if (!exists($senders->{$sender_key})
        && scalar(keys %$senders) >= netbar_away_sender_limit()) {
        $state->{overflow_count}++;
        return;
    }

    my $sender = ($senders->{$sender_key} ||= {
        nick         => $nick,
        count        => 0,
        recent       => [],
        channels     => {},
        first_at     => $now,
        last_at      => $now,
        notice_until => 0,
        spam         => 0,
    });

    $sender->{nick} = $nick;
    $sender->{count}++;
    $sender->{last_at} = $now;
    $sender->{channels}{$channel_name} = 1;
    return if $sender->{spam};

    my $spam_window = netbar_away_setting_seconds(
        'network_statusbars_away_spam_window', 60,
    );
    my $recent = ($sender->{recent} ||= []);
    @$recent = grep { $_ >= $now - $spam_window } @$recent;
    push @$recent, $now;

    my $spam_limit = Irssi::settings_get_int(
        'network_statusbars_away_spam_limit'
    );
    $spam_limit = 5 if !defined($spam_limit) || $spam_limit < 1;

    my $total_limit = Irssi::settings_get_int(
        'network_statusbars_away_spam_total_limit'
    );
    $total_limit = 20 if !defined($total_limit) || $total_limit < 1;

    if (!$sender->{spam}
        && (@$recent >= $spam_limit || $sender->{count} >= $total_limit)) {
        netbar_away_mark_sender_spam($state, $sender_key, $sender);
    }

    if (!$sender->{spam}) {
        my $max_lines = Irssi::settings_get_int(
            'network_statusbars_away_report_max_lines'
        );
        $max_lines = 200 if !defined($max_lines) || $max_lines < 1;
        $max_lines = 1000 if $max_lines > 1000;

        my $entries = ($state->{entries} ||= []);
        if (@$entries < $max_lines) {
            my $stored_message = Irssi::settings_get_bool(
                'network_statusbars_away_store_message_text'
            ) ? netbar_away_clean_plain_text($message) : '';
            push @$entries, {
                time       => $now,
                sender_key => $sender_key,
                nick       => $nick,
                channel    => $channel_name,
                message    => $stored_message,
            };
        }
        else {
            $state->{overflow_count}++;
        }

        # A standalone mention anywhere in the line is considered directed at
        # us. Per-sender cooldown and global burst protection prevent NOTICE
        # loops or floods while still covering "hi, mxl are you there?".
        netbar_away_send_notice($server, $state, $sender);
    }
}

sub netbar_away_status_print {
    my ($line) = @_;
    $line = '' unless defined $line;

    # print text is synchronous in Irssi. Silence the generic Status hook while
    # this script prints command feedback or a report line; report_session marks
    # the correct network explicitly before printing its first line.
    $status_attention_suppression_depth++;
    my $window = eval { Irssi::window_find_refnum(1) };
    if ($window) {
        $window->print($line, Irssi::MSGLEVEL_CLIENTNOTICE());
    }
    else {
        Irssi::print($line, Irssi::MSGLEVEL_CLIENTNOTICE());
    }
    $status_attention_suppression_depth--
        if $status_attention_suppression_depth > 0;
}

sub netbar_away_report_session {
    my ($state) = @_;
    return unless $state;

    my $network = netbar_away_status_text(
        $state->{network} || $state->{tag} || 'network'
    );

    if ($state->{mass_active}) {
        my $unique = int($state->{mass_unique_count} // 0);
        my $messages = int($state->{mass_message_count} // 0);
        my $identity_overflow = int($state->{mass_identity_overflow} // 0);
        my @channels = sort keys %{ $state->{mass_channels} || {} };
        my $channel_count = scalar @channels;
        my @shown = @channels > 5 ? @channels[0 .. 4] : @channels;
        my $where = @shown
            ? join(', ', map { netbar_away_status_text($_) } @shown)
            : '?';
        $where .= ' +' . ($channel_count - @shown)
            if $channel_count > @shown;

        netbar_mark_status_attention($state->{tag} || $state->{network});
        netbar_away_status_print(
            '[AWAY] ' . $network
            . ': mass mention spam detected from at least ' . $unique
            . ' distinct nicknames'
            . ($identity_overflow ? ' (tracking limit reached)' : '')
            . ' (' . $messages . ' messages) on '
            . $where
            . '; message text was not stored and automatic NOTICE replies were paused.'
        );
        return;
    }

    my @entries = sort {
        ($a->{time} // 0) <=> ($b->{time} // 0)
    } @{ $state->{entries} || [] };
    my @spammers = sort {
        lc($a->{nick} // '') cmp lc($b->{nick} // '')
    } grep {
        $_->{spam}
    } values %{ $state->{senders} || {} };

    return unless @entries || @spammers
        || ($state->{overflow_count} // 0) > 0
        || ($state->{notices_suppressed} // 0) > 0;

    netbar_mark_status_attention($state->{tag} || $state->{network});
    netbar_away_status_print(
        '[AWAY] ' . $network . ': mentions while away:'
    );

    for my $entry (@entries) {
        my $clock = strftime('%H:%M:%S', localtime(int($entry->{time} // time())));
        my $nick = netbar_away_status_text($entry->{nick} // '?');
        my $channel = netbar_away_status_text($entry->{channel} // '?');
        my $message = netbar_away_status_text($entry->{message} // '');
        my $detail = length($message)
            ? ': ' . $message
            : ' (message text was not stored)';
        netbar_away_status_print(
            '[' . $clock . '] ' . $nick . ' on ' . $channel . $detail
        );
    }

    for my $sender (@spammers) {
        my $nick = netbar_away_status_text($sender->{nick} // '?');
        my @channels = sort keys %{ $sender->{channels} || {} };
        my $channels = @channels
            ? join(', ', map { netbar_away_status_text($_) } @channels)
            : '?';
        my $count = int($sender->{count} // 0);
        netbar_away_status_print(
            '[AWAY] ' . $network . ': spam from ' . $nick . ' on '
            . $channels . ' (' . $count . ' mention messages).'
        );
    }

    if (($state->{overflow_count} // 0) > 0) {
        netbar_away_status_print(
            '[AWAY] ' . $network . ': memory protection skipped '
            . int($state->{overflow_count})
            . ' excess mentions; their text was not retained.'
        );
    }

    if (($state->{notices_suppressed} // 0) > 0) {
        netbar_away_status_print(
            '[AWAY] ' . $network . ': flood protection suppressed '
            . int($state->{notices_suppressed})
            . ' automatic NOTICE replies.'
        );
    }
}

sub netbar_away_connected_servers {
    return grep { $_ && $_->{connected} } Irssi::servers();
}

sub netbar_away_server_label {
    my ($server) = @_;
    return '?' unless $server;
    return netbar_away_status_text(
        netbar_network_name_for_server($server)
            || netbar_server_tag($server)
            || '?'
    );
}

sub netbar_away_parse_command {
    my ($data) = @_;
    $data = netbar_away_clean_plain_text($data // '');
    $data = substr($data, 0, 220) if length($data) > 220;

    return ('off', '') if lc($data) eq 'off';
    return ('set', length($data) ? $data : 'Away');
}

sub netbar_away_send_set {
    my ($server, $reason, $automatic) = @_;
    return 0 unless $server && $server->{connected};

    $reason = netbar_away_clean_plain_text($reason // 'Away');
    $reason = 'Away' unless length $reason;
    $reason = substr($reason, 0, 220) if length($reason) > 220;

    my $key = netbar_away_session_key($server);
    my $had_auto = length($key) && exists $auto_away_servers{$key}
        ? $auto_away_servers{$key}
        : undef;

    if (length $key) {
        if ($automatic) {
            $auto_away_servers{$key} = time();
        }
        else {
            delete $auto_away_servers{$key};
        }
    }

    # The server normally echoes an AWAY acknowledgement into Status. It is
    # command feedback, not unread traffic, so keep the matching network quiet
    # briefly. A later return report bypasses this guard and marks itself.
    netbar_quiet_status_attention_for_server($server, 4);
    my $ok = eval {
        $server->send_raw('AWAY :' . $reason);
        1;
    };
    if (!$ok && length $key) {
        if (defined $had_auto) {
            $auto_away_servers{$key} = $had_auto;
        }
        else {
            delete $auto_away_servers{$key};
        }
    }

    return $ok ? 1 : 0;
}

sub netbar_away_send_clear {
    my ($server) = @_;
    return 0 unless $server && $server->{connected};

    my $key = netbar_away_session_key($server);
    my $had_auto = length($key) && exists($auto_away_servers{$key}) ? 1 : 0;
    my $auto_owned_at = $had_auto ? $auto_away_servers{$key} : undef;

    # Suppress the ordinary server acknowledgement. If this network collected
    # mentions, netbar_away_report_session() explicitly lights only its header.
    netbar_quiet_status_attention_for_server($server, 4);
    my $ok = eval {
        $server->send_raw('AWAY');
        1;
    } ? 1 : 0;

    if ($ok) {
        delete $auto_away_servers{$key} if length $key;
    }
    elsif ($had_auto && length $key) {
        # Keep ownership when the network write failed, allowing the next user
        # activity or /autoaway off to retry instead of leaving a stuck AWAY.
        $auto_away_servers{$key} = $auto_owned_at;
    }
    return $ok;
}

sub netbar_command_global_away {
    my ($data, $server, $witem) = @_;
    my ($mode, $reason) = netbar_away_parse_command($data);

    my @servers = netbar_away_connected_servers();
    if (!@servers) {
        netbar_away_status_print('[GAWAY] No connected networks.');
        return;
    }

    my @networks;
    my $sent = 0;
    if ($mode eq 'off') {
        # Explicitly returning from AWAY counts as present activity, otherwise an
        # already-expired idle timer could immediately set automatic AWAY again.
        $last_channel_activity_at = time();

        for my $srv (@servers) {
            next unless netbar_away_send_clear($srv);
            $sent++;
            push @networks, netbar_away_server_label($srv);
        }
    }
    else {
        for my $srv (@servers) {
            # A manual /gaway always takes ownership from the idle automation.
            next unless netbar_away_send_set($srv, $reason, 0);
            $sent++;
            push @networks, netbar_away_server_label($srv);
        }
    }

    if (!$sent) {
        netbar_away_status_print('[GAWAY] Failed to send the AWAY command.');
        return;
    }

    my $network_list = join(', ', @networks);
    if ($mode eq 'off') {
        netbar_away_status_print(
            '[GAWAY] Cleared AWAY on ' . $sent . ' networks: '
            . $network_list
        );
    }
    else {
        netbar_away_status_print(
            '[GAWAY] Set AWAY on ' . $sent . ' networks: '
            . $network_list . ' - ' . netbar_away_status_text($reason)
        );
    }
}

sub netbar_command_single_away {
    my ($data, $server, $witem) = @_;

    # This handler runs before Irssi's built-in /away implementation. The custom
    # syntax is intentionally simple: /away [reason] and /away off.
    Irssi::signal_stop();

    $data = '' unless defined $data;
    $data =~ s/^\s+|\s+$//g;

    # Preserve useful compatibility with the native flags.
    if ($data =~ s/^-all(?:\s+|$)//i) {
        netbar_command_global_away($data, $server, $witem);
        return;
    }
    $data =~ s/^-one(?:\s+|$)//i;

    $server ||= $witem->{server} if $witem && $witem->{server};
    $server ||= Irssi::active_server();
    if (!$server || !$server->{connected}) {
        netbar_away_status_print('[AWAY] No active connected network.');
        return;
    }

    my ($mode, $reason) = netbar_away_parse_command($data);
    my $network = netbar_away_server_label($server);

    if ($mode eq 'off') {
        $last_channel_activity_at = time();
        if (netbar_away_send_clear($server)) {
            netbar_away_status_print(
                '[AWAY] Cleared AWAY on ' . $network . '.'
            );
        }
        else {
            netbar_away_status_print(
                '[AWAY] Failed to clear AWAY on ' . $network . '.'
            );
        }
        return;
    }

    if (netbar_away_send_set($server, $reason, 0)) {
        netbar_away_status_print(
            '[AWAY] Set AWAY on ' . $network . ' - '
            . netbar_away_status_text($reason)
        );
    }
    else {
        netbar_away_status_print(
            '[AWAY] Failed to set AWAY on ' . $network . '.'
        );
    }
}

sub netbar_auto_away_set_idle_networks {
    return unless Irssi::settings_get_bool(
        'network_statusbars_auto_away_enabled'
    );

    my @set;
    for my $server (netbar_away_connected_servers()) {
        # An existing AWAY without our ownership is manual (also after a script
        # reload), therefore it must never be overwritten by automatic AWAY.
        next if netbar_away_server_is_active($server);

        my $key = netbar_away_session_key($server);
        next if length($key) && exists $auto_away_servers{$key};

        if (netbar_away_send_set($server, 'Away', 1)) {
            push @set, netbar_away_server_label($server);
        }
    }

    if (@set) {
        my $minutes = int(netbar_away_setting_seconds(
            'network_statusbars_auto_away_idle', 3600,
        ) / 60);
        $minutes = 60 if $minutes < 1;
        netbar_away_status_print(
            '[AUTO-AWAY] After ' . $minutes
            . ' min of inactivity, set AWAY on: '
            . join(', ', @set)
            . '. Existing manual AWAY settings were preserved.'
        );
    }
}

sub netbar_auto_away_clear_owned {
    my ($cause) = @_;
    my @cleared;

    for my $server (netbar_away_connected_servers()) {
        my $key = netbar_away_session_key($server);
        next unless length($key) && exists $auto_away_servers{$key};

        if (netbar_away_send_clear($server)) {
            push @cleared, netbar_away_server_label($server);
        }
    }

    if (@cleared) {
        my $prefix = defined($cause) && $cause eq 'disabled'
            ? '[AUTO-AWAY] Disabled; cleared automatic AWAY on: '
            : '[AUTO-AWAY] Channel activity cleared automatic AWAY on: ';
        netbar_away_status_print(
            $prefix . join(', ', @cleared)
            . '. Manual AWAY settings were preserved.'
        );
    }
    return scalar @cleared;
}

# Record one confirmed piece of channel activity in one place. Calling this
# more than once for the same outgoing line is harmless: the first call clears
# the owned markers and later fallbacks simply refresh the idle timestamp.
sub netbar_auto_away_note_channel_activity {
    $last_channel_activity_at = time();
    netbar_auto_away_clear_owned();
}

sub netbar_auto_away_is_joined_channel_item {
    my ($item) = @_;
    return 0 unless $item && $item->{server};

    my $type = uc($item->{type} // '');
    return 0 unless $type eq 'CHANNEL' || ref($item) =~ /Channel/i;
    return $item->{joined} ? 1 : 0;
}

sub netbar_auto_away_channel_activity {
    my ($server, $message, $target) = @_;
    return unless $server && defined($target) && length($target);

    my $channel = netbar_find_message_channel($server, $target);
    return unless $channel && $channel->{joined};

    netbar_auto_away_note_channel_activity();
}

# "send text" is emitted directly from the input path and is therefore more
# dependable than waiting only for message own_public. It also fires early
# enough to send AWAY clear before the user's new channel line. Commands other
# than /me and /action do not count as channel activity; //escaped text does.
sub netbar_auto_away_send_text_activity {
    my ($line, $server, $window_item) = @_;
    return unless defined($line) && length($line);
    return unless netbar_auto_away_is_joined_channel_item($window_item);

    my $trimmed = $line;
    $trimmed =~ s/^\s+//;
    return unless length $trimmed;
    return if $trimmed =~ m{^/(?!/)}
        && $trimmed !~ m{^/(?:me|action)(?:\s|$)}i;

    netbar_auto_away_note_channel_activity();
}

# Some Irssi builds route actions straight through command signals without a
# useful send-text callback. Observe both spellings without stopping or
# replacing the native command.
sub netbar_auto_away_command_action_activity {
    my ($data, $server, $window_item) = @_;
    return unless defined($data) && $data =~ /\S/;
    return unless netbar_auto_away_is_joined_channel_item($window_item);

    netbar_auto_away_note_channel_activity();
}

sub netbar_auto_away_timer_tick {
    return unless Irssi::settings_get_bool(
        'network_statusbars_auto_away_enabled'
    );

    my $idle_seconds = netbar_away_setting_seconds(
        'network_statusbars_auto_away_idle',
        3600,
    );
    $idle_seconds = 3600 if $idle_seconds < 60;

    return if time() - $last_channel_activity_at < $idle_seconds;
    netbar_auto_away_set_idle_networks();
}

sub netbar_auto_away_restart_timer {
    if ($auto_away_timer_tag) {
        Irssi::timeout_remove($auto_away_timer_tag);
        $auto_away_timer_tag = undef;
    }

    return unless Irssi::settings_get_bool(
        'network_statusbars_auto_away_enabled'
    );

    $auto_away_timer_tag = Irssi::timeout_add(
        30_000,
        'netbar_auto_away_timer_tick',
        0,
    );
}

sub netbar_auto_away_parse_duration_minutes {
    my ($value) = @_;
    $value = lc($value // '');
    $value =~ s/^\s+|\s+$//g;

    my $minutes;
    if ($value =~ /\A(\d+)\s*(?:m|min|mins|minute|minutes)?\z/) {
        $minutes = int($1);
    }
    elsif ($value =~ /\A(\d+)\s*(?:h|hr|hrs|hour|hours)\z/) {
        $minutes = int($1) * 60;
    }
    return undef unless defined($minutes)
        && $minutes >= 1 && $minutes <= 10_080;
    return $minutes;
}

sub netbar_command_autoaway {
    my ($data) = @_;
    $data = '' unless defined $data;
    $data =~ s/^\s+|\s+$//g;
    my $command = lc($data);
    $command = 'status' unless length $command;

    if ($command eq 'on') {
        Irssi::settings_set_bool('network_statusbars_auto_away_enabled', 1);
        # Enabling starts a fresh configured interval instead of immediately
        # consuming inactivity accumulated while the feature was disabled.
        $last_channel_activity_at = time();
        netbar_auto_away_restart_timer();
        my $minutes = int(netbar_away_setting_seconds(
            'network_statusbars_auto_away_idle', 3600,
        ) / 60);
        $minutes = 60 if $minutes < 1;
        netbar_away_status_print(
            '[AUTO-AWAY] ON - global AWAY starts after ' . $minutes
            . ' min without '
            . 'a channel message or /me from you.'
        );
        return;
    }

    if ($command eq 'off') {
        Irssi::settings_set_bool('network_statusbars_auto_away_enabled', 0);
        netbar_auto_away_restart_timer();
        my $cleared = netbar_auto_away_clear_owned('disabled');
        netbar_away_status_print(
            '[AUTO-AWAY] OFF - global AWAY will not be set automatically. '
            . 'Manual AWAY settings are preserved.'
        ) unless $cleared;
        return;
    }

    if ($command =~ /\Aduration(?:\s+(.+))?\z/) {
        my $minutes = netbar_auto_away_parse_duration_minutes($1 // '');
        if (!defined $minutes) {
            netbar_away_status_print(
                'Usage: /autoaway duration 1-10080[min] (e.g. 60min, 120min or 2h)'
            );
            return;
        }

        Irssi::settings_set_time(
            'network_statusbars_auto_away_idle',
            $minutes * 60_000,
        );
        # Changing the threshold starts a new full interval. This avoids an
        # immediate AWAY when the new duration is shorter than elapsed idle.
        $last_channel_activity_at = time();
        netbar_auto_away_restart_timer();
        my $enabled = Irssi::settings_get_bool(
            'network_statusbars_auto_away_enabled'
        ) ? 'ON' : 'OFF';
        netbar_away_status_print(
            '[AUTO-AWAY] Timeout set to ' . $minutes
            . ' min | status: ' . $enabled
            . ' | countdown starts now.'
        );
        return;
    }

    if ($command eq 'status') {
        my $enabled = Irssi::settings_get_bool(
            'network_statusbars_auto_away_enabled'
        ) ? 'ON' : 'OFF';
        my $minutes = int(netbar_away_setting_seconds(
            'network_statusbars_auto_away_idle', 3600,
        ) / 60);
        $minutes = 60 if $minutes < 1;
        my $notice = Irssi::settings_get_bool(
            'network_statusbars_away_notice_enabled'
        ) ? 'ON' : 'OFF';
        my $message = Irssi::settings_get_bool(
            'network_statusbars_away_store_message_text'
        ) ? 'ON' : 'OFF';
        netbar_away_status_print(
            '[AUTO-AWAY] ' . $enabled . ' | idle timeout: ' . $minutes
            . ' min | NOTICE: ' . $notice
            . ' | full report text: ' . $message . '.'
        );
        return;
    }

    netbar_away_status_print(
        'Usage: /autoaway on|off|status|duration 1-10080[min]'
    );
}

sub netbar_apply_behavior_defaults_migration {
    my $revision = Irssi::settings_get_int(
        'network_statusbars_behavior_defaults_revision'
    );
    $revision = 0 unless defined $revision;

    # Revision markers make each public-default migration a one-time upgrade,
    # so settings explicitly changed afterwards remain respected on reload.
    if ($revision < 1) {
        Irssi::settings_set_bool('network_statusbars_away_notice_enabled', 1);
        Irssi::settings_set_bool('network_statusbars_away_store_message_text', 1);
    }
    if ($revision < 2) {
        # 5.1.28 changes the public default to opt-in auto-away. Existing users
        # are moved to OFF exactly once; /autoaway on then persists normally.
        Irssi::settings_set_bool('network_statusbars_auto_away_enabled', 0);
    }
    Irssi::settings_set_int(
        'network_statusbars_behavior_defaults_revision', 2,
    ) if $revision < 2;
}


sub netbar_command_channel_context {
    my ($data, $server, $witem) = @_;
    $data = '' unless defined $data;
    $data =~ s/^\s+|\s+$//g;

    $server ||= $witem->{server} if $witem && $witem->{server};
    if (!$server) {
        my $window = Irssi::active_win();
        my $item = $window ? $window->{active} : undef;
        $server = $item->{server} if $item && $item->{server};
    }

    my $channel_name = $data;
    if (!length($channel_name) && $witem) {
        my $kind = netbar_item_kind($witem);
        $channel_name = $witem->{name} // '' if $kind eq 'CHANNEL';
    }

    return (undef, undef, 'No active network.') unless $server;
    return (undef, undef, 'Usage: /mute #channel or /unmute #channel.')
        unless length $channel_name;

    my $channel = eval { $server->channel_find($channel_name) };
    return (undef, undef, 'Joined channel not found: ' . $channel_name
        . ' on the current network.')
        unless $channel && $channel->{joined};

    return ($server, $channel, '');
}

sub netbar_command_mute {
    my ($data, $server, $witem) = @_;
    my ($srv, $channel, $error) =
        netbar_command_channel_context($data, $server, $witem);
    if (length $error) {
        netbar_away_status_print('[MUTE] ' . netbar_away_status_text($error));
        return;
    }

    my $key = netbar_channel_key($srv, $channel->{name});
    return unless length $key;

    my $network = netbar_away_status_text(
        netbar_network_name_for_server($srv) || netbar_server_tag($srv) || '?'
    );
    my $name = netbar_away_status_text($channel->{name} // '?');

    if (netbar_channel_is_muted($srv, $channel->{name})) {
        netbar_away_status_print('[MUTE] ' . $network . '/' . $name
            . ': channel is already muted.');
        return;
    }

    $muted_channels{$key} = 1;
    delete $message_activity{$key};
    delete $nick_attention{$key};
    delete $channel_unread_count{$key};
    matrix_mentions_clear($key);
    netbar_muted_channels_save();
    netbar_schedule_refresh();
    netbar_away_status_print('[MUTE] ' . $network . '/' . $name
        . ': notifications muted until /unmute.');
}

sub netbar_command_unmute {
    my ($data, $server, $witem) = @_;
    my ($srv, $channel, $error) =
        netbar_command_channel_context($data, $server, $witem);
    if (length $error) {
        netbar_away_status_print('[MUTE] ' . netbar_away_status_text($error));
        return;
    }

    my $key = netbar_channel_key($srv, $channel->{name});
    return unless length $key;

    my $network = netbar_away_status_text(
        netbar_network_name_for_server($srv) || netbar_server_tag($srv) || '?'
    );
    my $name = netbar_away_status_text($channel->{name} // '?');

    if (!netbar_channel_is_muted($srv, $channel->{name})) {
        netbar_away_status_print('[MUTE] ' . $network . '/' . $name
            . ': channel is not muted.');
        return;
    }

    delete $muted_channels{$key};
    delete $message_activity{$key};
    delete $nick_attention{$key};
    delete $channel_unread_count{$key};
    matrix_mentions_clear($key);
    netbar_muted_channels_save();
    netbar_schedule_refresh();
    netbar_away_status_print('[MUTE] ' . $network . '/' . $name
        . ': notifications unmuted.');
}

sub netbar_signal_away_mode_changed {
    my ($server) = @_;
    return unless $server;

    my $key = netbar_away_session_key($server);
    if (netbar_away_server_is_active($server)) {
        netbar_away_session_for_server($server, 1);
    }
    elsif (length($key)) {
        delete $auto_away_servers{$key};
        my $state = delete $away_mention_sessions{$key};
        netbar_away_report_session($state) if $state;
    }

    netbar_schedule_refresh();
}

sub netbar_signal_channel_action { netbar_signal_channel_message(@_, 1); }

sub netbar_signal_channel_message {
    my ($server, $message, $nick, $address, $target, $is_action) = @_;
    # Servers supporting echo-message may reflect our own outgoing text.
    return if $server && defined($nick) && length($nick)
        && matrix_irc_casefold($server, $nick) eq matrix_irc_casefold($server, $server->{nick});
    my $channel = netbar_find_message_channel($server, $target);
    return unless $channel && $channel->{joined};
    my $level = Irssi::MSGLEVEL_PUBLIC();
    $level |= Irssi::MSGLEVEL_ACTIONS() if $is_action;
    return if $server->ignore_check($nick, $address // '', $channel->{name}, $message, $level);

    my $key = netbar_channel_key($server, $channel->{name});
    return unless length $key;

    # A muted channel remains visible and continues receiving normal lines, but
    # it creates no sidebar activity, nick alert, AWAY NOTICE or AWAY report.
    if (netbar_channel_is_muted($server, $channel->{name})) {
        delete $message_activity{$key};
        delete $nick_attention{$key};
        delete $channel_unread_count{$key};
        matrix_mentions_clear($key);
        netbar_schedule_refresh();
        return;
    }

    my $mentions_own_nick = netbar_message_mentions_own_nick($server, $message);
    if ($mentions_own_nick) {
        netbar_away_record_mention(
            $server,
            $message,
            $nick,
            $channel->{name},
        );
    }

    if (netbar_channel_is_current($channel)) {
        delete $message_activity{$key};
        delete $nick_attention{$key};
        delete $channel_unread_count{$key};
        matrix_mentions_clear($key);
    }
    else {
        $channel_unread_count{$key} = ($channel_unread_count{$key} // 0) + 1;
        if ($mentions_own_nick) {
            # An own-nick mention has its own stronger steady indication.
            # The window number stays lit until the channel is opened.
            $nick_attention{$key} = 1;
            delete $message_activity{$key};
        }
        else {
            $message_activity{$key} = 1;
        }
    }

    netbar_schedule_refresh();
}

sub netbar_item_kind {
    my ($item) = @_;
    return '' unless $item;

    my $type = uc($item->{type} // '');
    return 'CHANNEL' if $type eq 'CHANNEL' || ref($item) =~ /Channel/i;
    return 'QUERY'   if $type eq 'QUERY'   || ref($item) =~ /Query/i;
    return '';
}

sub netbar_remember_active_item {
    my $window = Irssi::active_win();
    return 0 unless $window;

    my $item = $window->{active};
    my $kind = netbar_item_kind($item);
    return 0 unless length $kind;
    return 0 unless $item->{server} && defined($item->{name})
        && length($item->{name});

    my $network = netbar_network_name_for_server($item->{server});
    return 0 unless length $network;

    my $tag = netbar_server_tag($item->{server});
    $last_item_by_network{lc $network} = {
        network => $network,
        tag     => $tag,
        name    => $item->{name},
        kind    => $kind,
        seen    => time(),
    };

    $last_channel_by_network{lc $network} = $item->{name}
        if $kind eq 'CHANNEL';
    return 1;
}

sub netbar_resolve_remembered_item {
    my ($network) = @_;
    return undef unless defined($network) && length($network);

    my $entry = $last_item_by_network{lc $network};
    return undef unless $entry;

    my $server = length($entry->{tag} // '')
        ? Irssi::server_find_tag($entry->{tag})
        : undef;
    if (!$server || !$server->{connected}) {
        delete $last_item_by_network{lc $network};
        return undef;
    }

    my $actual_network = netbar_network_name_for_server($server);
    if (!length($actual_network) || lc($actual_network) ne lc($network)) {
        delete $last_item_by_network{lc $network};
        return undef;
    }

    my $item;
    if (($entry->{kind} // '') eq 'QUERY') {
        $item = eval { $server->query_find($entry->{name}) };
    }
    else {
        $item = eval { $server->channel_find($entry->{name}) };
        $item = undef if $item && !$item->{joined};
    }

    if (!$item) {
        delete $last_item_by_network{lc $network};
        return undef;
    }

    # Keep the remembered spelling current after case-only nick/channel changes.
    $entry->{name} = $item->{name}
        if defined($item->{name}) && length($item->{name});
    return $item;
}

sub netbar_first_query_for_network {
    my ($network) = @_;
    my @queries;

    for my $query (Irssi::queries()) {
        next unless $query && $query->{server} && defined($query->{name});
        my $query_network = netbar_network_name_for_server($query->{server});
        next unless length($query_network)
            && lc($query_network) eq lc($network);

        my $window = eval { $query->window() };
        next unless $window && defined($window->{refnum});
        push @queries, {
            item   => $query,
            key    => pmu_query_key_from_item($query),
            refnum => int($window->{refnum}),
            name   => $query->{name},
        };
    }

    @queries = sort {
        ($matrix_pm_recent{$b->{key}} // 0) <=> ($matrix_pm_recent{$a->{key}} // 0)
            || $a->{refnum} <=> $b->{refnum}
            || lc($a->{name}) cmp lc($b->{name})
    } @queries;

    return @queries ? $queries[0]->{item} : undef;
}

sub netbar_fallback_item_for_network {
    my ($network) = @_;

    my @channels = netbar_channel_entries_for_network($network);
    if (@channels) {
        my $selected = netbar_selected_entry_for_network($network, \@channels);
        return $selected->{channel} if $selected && $selected->{channel};
    }

    return netbar_first_query_for_network($network);
}

sub netbar_server_for_network {
    my ($network) = @_;
    return undef unless defined($network) && length($network);

    for my $server (Irssi::servers()) {
        next unless $server && $server->{connected};
        my $candidate = netbar_network_name_for_server($server);
        return $server if length($candidate)
            && lc($candidate) eq lc($network);
    }
    return undef;
}

sub netbar_focus_item {
    my ($item) = @_;
    my $active = Irssi::active_win();
    if ($active && netbar_sidebar_is_window($active)) {
        my $content = netbar_sidebar_content_window();
        Irssi::command('^window goto ' . int($content->{refnum}))
            if $content && defined($content->{refnum});
    }
    return 0 unless $item && $item->{server}
        && defined($item->{name}) && length($item->{name});

    my $window = eval { $item->window() };
    return 0 unless $window && defined($window->{refnum});

    netbar_sidebar_disable_autostick();
    netbar_sidebar_unstick_window($window);

    my $tag  = netbar_server_tag($item->{server});
    my $name = $item->{name};
    my $kind = netbar_item_kind($item);

    Irssi::command('^window goto ' . int($window->{refnum}));

    # Reacquire and activate the exact Windowitem. Name-only /WINDOW ITEM GOTO
    # is ambiguous when two networks have an item with the same name.
    my $fresh_server = length($tag) ? Irssi::server_find_tag($tag) : undef;
    my $fresh_item;
    if ($fresh_server && $kind eq 'QUERY') {
        $fresh_item = eval { $fresh_server->query_find($name) };
    }
    elsif ($fresh_server && $kind eq 'CHANNEL') {
        $fresh_item = eval { $fresh_server->channel_find($name) };
    }
    return 0 unless $fresh_item;

    my $active_window = Irssi::active_win();
    my $active_item = $active_window ? $active_window->{active} : undef;
    my $same_item = $active_item
        && $active_item->{server} && $fresh_item && $fresh_item->{server}
        && netbar_server_tag($active_item->{server})
            eq netbar_server_tag($fresh_item->{server})
        && defined($active_item->{name}) && defined($fresh_item->{name})
        && matrix_irc_casefold($active_item->{server}, $active_item->{name}) eq matrix_irc_casefold($fresh_item->{server}, $fresh_item->{name});

    if (!$same_item && $fresh_item) {
        my $activated = eval { $fresh_item->set_active(); 1 };
        # Compatibility fallback for unusually old Irssi builds.
        Irssi::command('^window item goto ' . $name) unless $activated;
    }

    # A focus change acknowledges the exact query only when its view is at
    # the bottom. Clicking a scrolled query preserves its unread time/colour.
    if ($kind eq 'QUERY' && defined &pmu_clear_query_item
            && !matrix_window_scrolled($window)) {
        pmu_clear_query_item($fresh_item);
        pmu_remember_query_item($fresh_item)
            if defined &pmu_remember_query_item;
    }

    netbar_remember_active_item();
    netbar_clear_current_channel_activity();
    netbar_schedule_refresh();
    clients_redraw_statusbar() if defined &clients_redraw_statusbar;
    return 1;
}

sub netbar_focus_network_status {
    my ($network) = @_;
    my $server = netbar_server_for_network($network);
    return 0 unless $server;

    netbar_sidebar_disable_autostick();
    my $status = eval { Irssi::window_find_refnum(1) };
    netbar_sidebar_unstick_window($status) if $status;

    Irssi::command('^window goto 1');
    my $tag = netbar_server_tag($server);
    Irssi::command('^window server ' . $tag) if length $tag;
    netbar_clear_status_attention($server);

    netbar_schedule_refresh();
    clients_redraw_statusbar() if defined &clients_redraw_statusbar;
    return 1;
}


# -------------------------------------------------------------------------
# Matrix channel transition
# -------------------------------------------------------------------------

sub netbar_matrix_in_screen {
    return (length($ENV{STY} // '') || ($ENV{TERM} // '') =~ /^screen(?:[.-]|$)/)
        ? 1 : 0;
}

sub netbar_matrix_raw_effects_allowed {
    # Irssi batches terminal output in C stdio. A Perl syswrite cannot join
    # that frame or know its pending cursor position. In Screen, use native
    # input/window rendering; preserve the user's effect settings for use
    # outside the multiplexer. Startup is covered by the same gate.
    return !netbar_matrix_in_screen();
}

sub netbar_matrix_terminal_size {
    my ($columns, $rows);

    eval {
        require Term::ReadKey;
        ($columns, $rows) = Term::ReadKey::GetTerminalSize(*STDOUT);
    };

    if (!$columns || !$rows) {
        my $winsize = '';
        if (open my $tty, '+<', '/dev/tty') {
            if (ioctl($tty, 0x5413, $winsize)) {
                my ($r, $c) = unpack('S4', $winsize);
                ($columns, $rows) = ($c, $r) if $c && $r;
            }
            close $tty;
        }
    }

    $columns = int($ENV{COLUMNS})
        if !$columns && defined($ENV{COLUMNS}) && $ENV{COLUMNS} =~ /\A\d+\z/;
    $rows = int($ENV{LINES})
        if !$rows && defined($ENV{LINES}) && $ENV{LINES} =~ /\A\d+\z/;

    $columns ||= netbar_active_width();
    $rows ||= $netbar_mouse_screen_height || 24;

    # In sidebar mode Matrix owns only the conversation side. The left split
    # and its one-cell divider stay visible during every frame.
    my $left_column = 0;
    my $sidebar = netbar_sidebar_window();
    if ($sidebar && int($sidebar->{width} // 0) > 0) {
        $left_column = int($sidebar->{width});
        $columns -= $left_column;
    }

    $columns = 0 if $columns < 1;
    $rows = 8 if $rows < 8;
    $columns = $MATRIX_MAX_MATRIX_COLUMNS
        if $columns > $MATRIX_MAX_MATRIX_COLUMNS;
    $rows = $MATRIX_MAX_MATRIX_ROWS
        if $rows > $MATRIX_MAX_MATRIX_ROWS;
    return (int($columns), int($rows), int($left_column));
}

sub netbar_matrix_write {
    my ($data) = @_;
    return 0 unless defined($data) && length($data);

    my $offset = 0;
    my $length = length($data);
    while ($offset < $length) {
        my $written = syswrite(STDERR, $data, $length - $offset, $offset);
        return 0 unless defined($written) && $written > 0;
        $offset += $written;
    }
    return 1;
}

sub netbar_matrix_reset_state {
    $netbar_matrix_running = 0;
    $netbar_matrix_frame = 0;
    $netbar_matrix_columns = 0;
    $netbar_matrix_rows = 0;
    $netbar_matrix_left_column = 0;
    $netbar_matrix_target_tag = '';
    $netbar_matrix_target_name = '';
    @netbar_matrix_heads = ();
    @netbar_matrix_speeds = ();
    @netbar_matrix_trails = ();
}

sub netbar_matrix_cancel {
    my ($redraw) = @_;

    if ($netbar_matrix_startup_timer_tag) {
        Irssi::timeout_remove($netbar_matrix_startup_timer_tag);
        undef $netbar_matrix_startup_timer_tag;
    }

    if ($netbar_matrix_timer_tag) {
        Irssi::timeout_remove($netbar_matrix_timer_tag);
        undef $netbar_matrix_timer_tag;
    }

    my $was_running = $netbar_matrix_running;
    netbar_matrix_reset_state();

    if ($was_running) {
        # The first Matrix frame hides the physical cursor. Restore it on every
        # cancellation path so curses and GNU Screen return to the visibility
        # state they had before the raw overlay began.
        netbar_matrix_write("\e[0m\e[?25h");
        Irssi::command('^redraw') if $redraw;
        netbar_matrix_input_cursor_schedule_apply(60)
            if defined &netbar_matrix_input_cursor_schedule_apply;
    }
}

sub netbar_matrix_finish {
    undef $netbar_matrix_timer_tag;
    return unless $netbar_matrix_running;

    my $tag = $netbar_matrix_target_tag;
    my $name = $netbar_matrix_target_name;

    # Do not clear the terminal here: CSI 2J would erase the retained sidebar.
    # Irssi redraws the destination conversation after the focus change.
    netbar_matrix_write("\e[0m");
    netbar_matrix_reset_state();

    my $server = length($tag) ? Irssi::server_find_tag($tag) : undef;
    my $channel = $server && length($name)
        ? eval { $server->channel_find($name) }
        : undef;
    netbar_focus_item($channel) if $channel && $channel->{joined};

    Irssi::command('^redraw');
    # DUAL restored cursor visibility explicitly after every animation. Do not
    # rely on curses to emit ?25h: its internal state never saw our raw ?25l.
    netbar_matrix_write("\e[0m\e[?25h");
    netbar_matrix_input_cursor_schedule_apply(60)
        if defined &netbar_matrix_input_cursor_schedule_apply;
}

sub netbar_matrix_frame_tick {
    undef $netbar_matrix_timer_tag;
    return unless $netbar_matrix_running;

    my $columns = $netbar_matrix_columns;
    my $rows = $netbar_matrix_rows;
    my $left_column = $netbar_matrix_left_column;
    my @glyphs = split //, '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ@#$%&*+=<>';

    my $buffer = $netbar_matrix_frame == 0
        ? "\e[?25l"
        : '';

    # Clear only the conversation side, then paint the short live trails. The
    # former rows*columns space matrix could build more than half a million
    # cells per frame on a large terminal. This sparse renderer produces the
    # same rain while its work scales with rows plus visible trail cells.
    for my $row (0 .. $rows - 1) {
        $buffer .= "\e[" . ($row + 1) . ';' . ($left_column + 1)
            . "H\e[0m\e[K";
    }

    for my $column (0 .. $columns - 1) {
        my $trail = $netbar_matrix_trails[$column] || 0;
        next unless $trail > 0;
        my $head = $netbar_matrix_heads[$column] // -10000;

        for my $distance (0 .. $trail - 1) {
            my $row = $head - $distance;
            next if $row < 0 || $row >= $rows;
            next if rand() < 0.12;

            my $style = $distance == 0 ? "\e[97m"
                : $distance <= 2 ? "\e[1;92m"
                : $distance <= 5 ? "\e[32m"
                : "\e[2;32m";
            $buffer .= "\e[" . ($row + 1) . ';'
                . ($left_column + $column + 1) . 'H'
                . $style . $glyphs[int(rand(@glyphs))];
        }
    }
    $buffer .= "\e[0m";

    netbar_matrix_write($buffer);

    for my $column (0 .. $columns - 1) {
        next unless ($netbar_matrix_trails[$column] || 0) > 0;
        $netbar_matrix_heads[$column] += $netbar_matrix_speeds[$column];
        if ($netbar_matrix_heads[$column] - $netbar_matrix_trails[$column]
            > $rows + 2) {
            $netbar_matrix_heads[$column] = -int(rand($rows / 2 + 1));
            $netbar_matrix_speeds[$column] = 1 + int(rand(3));
            $netbar_matrix_trails[$column] = 4 + int(rand(10));
        }
    }

    $netbar_matrix_frame++;

    my $duration = Irssi::settings_get_int('matrix_duration_ms');
    $duration = 360 if !defined($duration) || $duration < 180;
    $duration = 900 if $duration > 900;
    my $interval = int($duration / $netbar_matrix_frames);
    $interval = 25 if $interval < 25;

    my $callback = $netbar_matrix_frame >= $netbar_matrix_frames
        ? 'netbar_matrix_finish'
        : 'netbar_matrix_frame_tick';
    $netbar_matrix_timer_tag = Irssi::timeout_add_once(
        $interval,
        $callback,
        0,
    );
}

sub netbar_matrix_begin {
    my ($target_tag, $target_name) = @_;
    # The channel-switch toggle is checked by netbar_matrix_start_for_item().
    # Startup Matrix remains independent, so /matrix off never disables the
    # startup effect or the Matrix glyph effect while typing.
    return 0 if $netbar_matrix_running;
    # Screen opt-in applies only to a channel/JOIN destination. Startup passes
    # an empty destination and keeps the original raw-effects safety gate.
    return 0 unless netbar_matrix_raw_effects_allowed()
        || (netbar_matrix_in_screen()
            && Irssi::settings_get_bool('matrix_screen_transition_enabled')
            && defined($target_name) && length($target_name));
    return 0 unless -t STDERR;

    netbar_matrix_input_cancel() if defined($netbar_matrix_input_current_key)
        || @netbar_matrix_input_queue
        || $netbar_matrix_input_overlay_active;

    $target_tag = '' unless defined $target_tag;
    $target_name = '' unless defined $target_name;

    my ($columns, $rows, $left_column) = netbar_matrix_terminal_size();
    return 0 unless $columns >= 20 && $rows >= 8;

    $netbar_matrix_columns = $columns;
    $netbar_matrix_rows = $rows;
    $netbar_matrix_left_column = $left_column;
    $netbar_matrix_target_tag = $target_tag;
    $netbar_matrix_target_name = $target_name;
    $netbar_matrix_frame = 0;
    $netbar_matrix_running = 1;

    @netbar_matrix_heads = ();
    @netbar_matrix_speeds = ();
    @netbar_matrix_trails = ();

    for my $column (0 .. $columns - 1) {
        # Keep roughly one third of columns empty so the effect stays elegant
        # rather than looking like terminal snow.
        if (rand() < 0.34) {
            $netbar_matrix_heads[$column] = -10000;
            $netbar_matrix_speeds[$column] = 0;
            $netbar_matrix_trails[$column] = 0;
            next;
        }

        $netbar_matrix_heads[$column] = int(rand($rows + 14)) - 7;
        $netbar_matrix_speeds[$column] = 1 + int(rand(3));
        $netbar_matrix_trails[$column] = 4 + int(rand(10));
    }

    netbar_matrix_frame_tick();
    return 1;
}

sub netbar_matrix_transition_mode {
    my $mode = lc(Irssi::settings_get_str('matrix_transition_mode') // 'window');
    return $mode eq 'join' ? 'join' : 'window';
}

sub netbar_matrix_start_for_item {
    my ($target) = @_;
    # WINDOW mode is the clickable-sidebar transition. JOIN mode is
    # handled separately from the command signal and must not animate ordinary
    # switching between already joined channels. Input/startup remain separate.
    return 0 unless Irssi::settings_get_bool('matrix_enabled');
    return 0 unless netbar_matrix_transition_mode() eq 'window';
    return 0 unless netbar_item_kind($target) eq 'CHANNEL';
    return 0 unless $target->{server} && defined($target->{name})
        && length($target->{name});

    my $target_tag = netbar_server_tag($target->{server});
    my $window = Irssi::active_win();
    my $current = $window ? $window->{active} : undef;
    my $current_kind = netbar_item_kind($current);

    if ($current_kind eq 'CHANNEL') {
        return 0 unless $current->{server} && defined($current->{name});
        my $current_tag = netbar_server_tag($current->{server});
        return 0 if lc($current_tag) eq lc($target_tag)
            && matrix_irc_casefold($current->{server}, $current->{name}) eq matrix_irc_casefold($target->{server}, $target->{name});

        return netbar_matrix_begin($target_tag, $target->{name});
    }

    # Status 1 and QUERY windows both lead to a real channel switch, so use the
    # same mouse-only transition. PM-to-PM, keyboard navigation and right-click
    # actions remain immediate.
    return netbar_matrix_begin($target_tag, $target->{name})
        if netbar_status_window_is_active() || $current_kind eq 'QUERY';

    return 0;
}

# -------------------------------------------------------------------------
# Matrix input effect -- typed character briefly appears as random Matrix glyphs
# -----------------------------------------------------------------------------

sub netbar_matrix_input_random_glyph {
    # Keep the pool strictly one terminal cell wide. Full-width Katakana would
    # make the native Irssi cursor position drift on many terminals.
    my $pool = '01ZXCVBNMASDFGHJKLQWERTYUIOP23456789#$%&*+<>[]{}';
    return substr($pool, int(rand(length($pool))), 1);
}

sub netbar_matrix_cursor_style {
    my $style = lc(Irssi::settings_get_str('matrix_cursor_style') // 'block');
    return $style eq 'underscore' ? 'underscore' : 'block';
}

sub netbar_matrix_cursor_shape_sequence {
    return netbar_matrix_cursor_style() eq 'underscore'
        ? "\e[4 q"   # DECSCUSR: steady underline
        : "\e[2 q";  # DECSCUSR: steady block
}

# MATRIX applies only the colour and non-blinking DECSCUSR shape at explicit
# lifecycle points. There is deliberately no periodic cursor timer. Visibility
# is restored only after a Matrix overlay which explicitly hid the cursor.
sub netbar_matrix_input_cursor_write_state {
    my ($include_colour) = @_;
    return unless -t STDERR;
    return if $netbar_matrix_running;
    return if defined($netbar_matrix_screen_attached) && !$netbar_matrix_screen_attached;

    my $state = ($include_colour ? "\e]12;#00ff00\a" : '')
        . "\e[?12l" . netbar_matrix_cursor_shape_sequence();
    return if $state eq $netbar_matrix_cursor_last_state;
    # Screen 4.x does not reliably forward the extended cursor controls.
    # DCS passes these non-positioning controls to the attached terminal.
    my $wire = netbar_matrix_in_screen() ? "\eP" . $state . "\e\\" : $state;
    $netbar_matrix_cursor_last_state = $state if netbar_matrix_write($wire);
}

sub netbar_matrix_input_cursor_apply_after_redraw {
    undef $netbar_matrix_cursor_apply_tag;
    return unless $netbar_matrix_cursor_controller_active;
    if ($netbar_matrix_running) {
        netbar_matrix_input_cursor_schedule_apply(250);
        return;
    }
    netbar_matrix_input_cursor_write_state(1);
}

sub netbar_matrix_input_cursor_schedule_apply {
    my ($delay) = @_;
    return unless $netbar_matrix_cursor_controller_active;
    $delay = int($delay // 350);
    $delay = 50 if $delay < 50;
    $delay = 2000 if $delay > 2000;

    Irssi::timeout_remove($netbar_matrix_cursor_apply_tag)
        if defined $netbar_matrix_cursor_apply_tag;
    $netbar_matrix_cursor_apply_tag = Irssi::timeout_add_once(
        $delay,
        'netbar_matrix_input_cursor_apply_after_redraw',
        0,
    );
}

sub netbar_matrix_input_cursor_apply {
    return unless -t STDERR;

    if (defined $netbar_matrix_cursor_apply_tag) {
        Irssi::timeout_remove($netbar_matrix_cursor_apply_tag);
        undef $netbar_matrix_cursor_apply_tag;
    }
    $netbar_matrix_cursor_controller_active = 1;
    if ($netbar_matrix_running) {
        netbar_matrix_input_cursor_schedule_apply(250);
        return;
    }
    netbar_matrix_input_cursor_write_state(1);
}

sub netbar_matrix_input_cursor_restore {
    $netbar_matrix_cursor_controller_active = 0;
    $netbar_matrix_cursor_last_state = '';

    if (defined $netbar_matrix_cursor_apply_tag) {
        Irssi::timeout_remove($netbar_matrix_cursor_apply_tag);
        undef $netbar_matrix_cursor_apply_tag;
    }
    return unless -t STDERR;

    # Restore the terminal's default cursor colour, shape and blink policy.
    my $reset = "\e]112\a\e[0 q";
    $reset = "\eP" . $reset . "\e\\" if netbar_matrix_in_screen();
    netbar_matrix_write($reset);
}

# GNU Screen marks its session socket 0600 while detached and 0700 while at
# least one display is attached. Polling that metadata does not touch the
# terminal. The single 0600 -> 0700 transition is the reliable reattach event
# which Irssi itself does not receive when screen -r returns at the same size.
sub netbar_matrix_screen_socket_candidates {
    my $sty = $ENV{STY} // '';
    return () unless $sty =~ /\A[A-Za-z0-9_.:-]+\z/;

    my @pw = getpwuid($<);
    my $login = defined($pw[0]) ? $pw[0] : '';
    my $home_dir = defined($pw[7]) ? $pw[7] : '';
    return () unless $login =~ /\A[A-Za-z0-9_.-]+\z/;

    my @dirs;
    my $configured_dir = $ENV{SCREENDIR} // '';
    push @dirs, $configured_dir
        if length($configured_dir) && substr($configured_dir, 0, 1) eq '/';
    push @dirs,
        '/run/screen/S-' . $login,
        '/var/run/screen/S-' . $login,
        '/usr/tmp/screens/S-' . $login,
        '/tmp/screens/S-' . $login;
    push @dirs, $home_dir . '/.screen'
        if length($home_dir) && substr($home_dir, 0, 1) eq '/';

    my %seen;
    return map { $_ . '/' . $sty }
        grep { length($_) && !$seen{$_}++ } @dirs;
}

sub netbar_matrix_screen_find_socket {
    for my $path (netbar_matrix_screen_socket_candidates()) {
        my @st = lstat($path);
        next unless @st && -S _;
        next unless int($st[4]) == int($<);
        return $path;
    }
    return '';
}

sub netbar_matrix_screen_attached_state {
    my $path = $netbar_matrix_screen_socket_path;
    my @st = length($path) ? lstat($path) : ();
    if (!@st || !-S _ || int($st[4]) != int($<)) {
        $path = netbar_matrix_screen_find_socket();
        $netbar_matrix_screen_socket_path = $path;
        @st = length($path) ? lstat($path) : ();
    }
    return undef unless @st && -S _ && int($st[4]) == int($<);

    my @precise = Time::HiRes::stat($path);
    $netbar_matrix_screen_socket_change = join(':', $st[0], $st[1],
        @precise ? sprintf('%.9f', $precise[10]) : $st[10]);

    # Screen's SOCKMODE adds the owner execute bit only while displays exist.
    return ($st[2] & 0100) ? 1 : 0;
}

sub netbar_matrix_screen_attach_recover {
    undef $netbar_matrix_screen_attach_recover_tag;
    my $attached = netbar_matrix_screen_attached_state();
    return unless defined($attached) && $attached;

    # Reattach is a lifecycle boundary, not a recurring repaint. Rebuild the
    # retained panel, recompute geometry and discard incomplete mouse packets.
    netbar_matrix_input_cancel();
    netbar_matrix_cancel(0);
    undef $netbar_mouse_drag;
    undef $netbar_mouse_press_target;
    undef $netbar_mouse_chat_click;
    $netbar_mouse_status = -1;
    $netbar_mouse_sgr_status = -1;
    $netbar_mouse_sgr_buffer = '';
    @netbar_mouse_sgr_press = ();
    $netbar_mouse_sgr_press_time = 0;
    $netbar_sidebar_last_signature = '';
    netbar_sidebar_forget_buffer();
    $netbar_matrix_cursor_last_state = '';
    Irssi::command('^resize');
    netbar_mouse_update_terminal_size();
    netbar_mouse_tracking_rearm();
    matrix_schedule_bottom_rows_redraw(1);
    netbar_schedule_refresh();
    Irssi::command('^redraw');
    netbar_matrix_input_cursor_schedule_apply(350);
}

sub netbar_matrix_screen_attach_tick {
    my $attached = netbar_matrix_screen_attached_state();
    return unless defined $attached;

    if (!defined $netbar_matrix_screen_attached) {
        $netbar_matrix_screen_attached = $attached;
        $netbar_matrix_screen_observed_change = $netbar_matrix_screen_socket_change;
        return;
    }

    my $changed = defined($netbar_matrix_screen_observed_change)
        && defined($netbar_matrix_screen_socket_change)
        && $netbar_matrix_screen_observed_change ne $netbar_matrix_screen_socket_change;
    if ($attached && (!$netbar_matrix_screen_attached || $changed)) {
        Irssi::timeout_remove($netbar_matrix_screen_attach_recover_tag)
            if defined $netbar_matrix_screen_attach_recover_tag;
        $netbar_matrix_screen_attach_recover_tag = Irssi::timeout_add_once(
            250,
            'netbar_matrix_screen_attach_recover',
            0,
        );
    }
    $netbar_matrix_screen_attached = $attached;
    $netbar_matrix_screen_observed_change = $netbar_matrix_screen_socket_change;
}

sub netbar_matrix_screen_attach_watch_start {
    return unless defined($ENV{STY}) && length($ENV{STY});
    return if defined $netbar_matrix_screen_attach_watch_tag;

    $netbar_matrix_screen_attached = netbar_matrix_screen_attached_state();
    $netbar_matrix_screen_observed_change = $netbar_matrix_screen_socket_change;
    $netbar_matrix_screen_attach_watch_tag = Irssi::timeout_add(
        500,
        'netbar_matrix_screen_attach_tick',
        0,
    );
}

sub netbar_matrix_screen_attach_watch_stop {
    Irssi::timeout_remove($netbar_matrix_screen_attach_watch_tag)
        if defined $netbar_matrix_screen_attach_watch_tag;
    Irssi::timeout_remove($netbar_matrix_screen_attach_recover_tag)
        if defined $netbar_matrix_screen_attach_recover_tag;
    undef $netbar_matrix_screen_attach_watch_tag;
    undef $netbar_matrix_screen_attach_recover_tag;
    undef $netbar_matrix_screen_attached;
    undef $netbar_matrix_screen_socket_change;
    undef $netbar_matrix_screen_observed_change;
    $netbar_matrix_screen_socket_path = '';
}

# Separate Screen permission for input only; startup keeps its original gate.
sub netbar_matrix_input_effects_allowed {
    return netbar_matrix_raw_effects_allowed()
        || (netbar_matrix_in_screen()
            && Irssi::settings_get_bool('matrix_screen_input_enabled'));
}

sub netbar_matrix_input_paint_glyph {
    return unless netbar_matrix_input_effects_allowed();
    return unless -t STDERR;
    my $glyph = netbar_matrix_input_random_glyph();

    # The terminal cursor is already exactly where Irssi will insert the next
    # character. Save it, paint one bright-green cell, then restore it. No full
    # screen/statusbar redraw is involved.
    netbar_matrix_write(
        "\e7\e[1;92m" . $glyph . "\e[0m\e8"
    );
    $netbar_matrix_input_overlay_active = 1;
}

sub netbar_matrix_input_replay_key {
    my ($key) = @_;
    return unless defined $key;

    # Re-emit the original key through Irssi's normal input path. The guard makes
    # this script ignore its own replay while every other native binding still
    # sees the key normally.
    $netbar_matrix_input_replaying = 1;
    my $ok = eval { Irssi::signal_emit('gui key pressed', int($key)); 1 };
    $netbar_matrix_input_replaying = 0;
    Irssi::print('Matrix input replay error: ' . $@, Irssi::MSGLEVEL_CLIENTNOTICE())
        unless $ok;
}

sub netbar_matrix_input_flush_pending {
    if ($netbar_matrix_input_timer_tag) {
        Irssi::timeout_remove($netbar_matrix_input_timer_tag);
        undef $netbar_matrix_input_timer_tag;
    }

    if (defined $netbar_matrix_input_current_key) {
        netbar_matrix_input_replay_key($netbar_matrix_input_current_key);
        undef $netbar_matrix_input_current_key;
    }

    while (@netbar_matrix_input_queue) {
        my $entry = shift @netbar_matrix_input_queue;
        next unless $entry && defined $entry->{key};
        netbar_matrix_input_replay_key($entry->{key});
    }

    $netbar_matrix_input_current_started = 0;
    $netbar_matrix_input_overlay_active = 0;
    Irssi::statusbar_items_redraw('input');
}

sub netbar_matrix_input_cancel {
    # Never lose characters that were already accepted from the keyboard. If an
    # animation is interrupted (Matrix transition, OFF, unload), commit them
    # immediately in their original order.
    netbar_matrix_input_flush_pending();
    if ($netbar_matrix_input_manual_test_tag) {
        Irssi::timeout_remove($netbar_matrix_input_manual_test_tag);
        undef $netbar_matrix_input_manual_test_tag;
    }
    $netbar_matrix_input_escape_state = 0;
    $netbar_matrix_input_escape_prefix = '';
    $netbar_matrix_input_escape_buffer = '';
    $netbar_matrix_input_bracketed_paste = 0;
    $netbar_matrix_input_last_physical_time = 0;
    $netbar_matrix_input_burst_count = 0;
    $netbar_matrix_input_burst_started = 0;
    $netbar_matrix_input_paste_until = 0;
}

sub netbar_matrix_input_start_next;

sub netbar_matrix_input_tick {
    undef $netbar_matrix_input_timer_tag;

    if (!Irssi::settings_get_bool('matrix_input_enabled')
        || !netbar_matrix_input_effects_allowed()
        || $netbar_matrix_running) {
        netbar_matrix_input_flush_pending();
        return;
    }

    return netbar_matrix_input_start_next()
        unless defined $netbar_matrix_input_current_key;

    my $hold = Irssi::settings_get_int('matrix_input_flash_ms');
    $hold = 35 if !defined($hold) || $hold < 20;
    $hold = 120 if $hold > 120;

    # When the user types extremely quickly, shorten the stable one-glyph hold
    # slightly instead of building visible input lag.
    $hold = 20 if @netbar_matrix_input_queue > 8 && $hold > 20;

    my $elapsed_ms = (time() - $netbar_matrix_input_current_started) * 1000.0;
    if ($elapsed_ms >= $hold) {
        my $key = $netbar_matrix_input_current_key;
        undef $netbar_matrix_input_current_key;
        $netbar_matrix_input_current_started = 0;
        $netbar_matrix_input_overlay_active = 0;

        netbar_matrix_input_replay_key($key);

        # Force the native input item to paint the real character before the
        # next Matrix glyph is placed at the new cursor position.
        Irssi::statusbar_items_redraw('input');
        netbar_matrix_input_start_next();
        return;
    }

    # The glyph was painted once when the physical key arrived. Keep that one
    # cell stable until the real character is replayed instead of replacing it
    # every 10 ms beside the independently blinking cursor.
    my $remaining = int($hold - $elapsed_ms);
    $remaining = 10 if $remaining < 10;
    $netbar_matrix_input_timer_tag = Irssi::timeout_add_once(
        $remaining,
        'netbar_matrix_input_tick',
        0,
    );
}

sub netbar_matrix_input_start_next {
    return if defined $netbar_matrix_input_current_key;
    return unless @netbar_matrix_input_queue;

    my $entry = shift @netbar_matrix_input_queue;
    return netbar_matrix_input_start_next()
        unless $entry && defined $entry->{key};

    $netbar_matrix_input_current_key = int($entry->{key});
    $netbar_matrix_input_current_started = time();

    # First glyph is painted immediately in the same callback that received the
    # physical keypress. This removes the old 10 ms post-key race completely.
    netbar_matrix_input_paint_glyph();
    $netbar_matrix_input_timer_tag = Irssi::timeout_add_once(
        10,
        'netbar_matrix_input_tick',
        0,
    );
}

sub netbar_matrix_input_key_hook {
    my ($key) = @_;
    return unless defined $key;

    # Keys replayed by this module must pass straight through to Irssi.
    return if $netbar_matrix_input_replaying;
    unless (netbar_matrix_input_effects_allowed()) {
        # A /SET or /RELOAD can revoke permission between two keys. Commit
        # accepted input before passing the next physical key to Irssi.
        netbar_matrix_input_cancel()
            if defined($netbar_matrix_input_current_key)
                || @netbar_matrix_input_queue || $netbar_matrix_input_overlay_active;
        return;
    }
    return unless Irssi::settings_get_bool('matrix_input_enabled');
    return if $netbar_matrix_running;

    # Mouse escape-sequence payload belongs entirely to the mouse parser.
    return if $netbar_mouse_status != -1 || $netbar_mouse_sgr_status != -1;

    # Irssi/terminal combinations differ here: some provide a numeric byte,
    # while TermX-like clients may provide the literal character. Preserve
    # uppercase letters instead of coercing them to zero with int('A').
    my $numeric_key = netbar_mouse_key_value($key);
    return unless defined $numeric_key;
    $key = $numeric_key;

    # Do not mistake terminal escape sequences (arrows, Alt bindings, function
    # keys) for printable text. Track CSI long enough to recognize standard
    # bracketed-paste markers as well, while still passing every byte to Irssi.
    if ($key == 27) {
        netbar_matrix_input_flush_pending()
            if defined($netbar_matrix_input_current_key)
                || @netbar_matrix_input_queue;
        $netbar_matrix_input_escape_state = 1;
        $netbar_matrix_input_escape_prefix = '';
        $netbar_matrix_input_escape_buffer = '';
        return;
    }
    if ($netbar_matrix_input_escape_state == 1) {
        if ($key == ord('[') || $key == ord('O')) {
            $netbar_matrix_input_escape_state = 2;
            $netbar_matrix_input_escape_prefix = chr($key);
            $netbar_matrix_input_escape_buffer = '';
        }
        else {
            $netbar_matrix_input_escape_state = 0;
            $netbar_matrix_input_escape_prefix = '';
            $netbar_matrix_input_escape_buffer = '';
        }
        return;
    }
    if ($netbar_matrix_input_escape_state == 2) {
        my $char = chr($key & 0xff);
        if (length($netbar_matrix_input_escape_buffer) < 31) {
            $netbar_matrix_input_escape_buffer .= $char;
        }
        else {
            $netbar_matrix_input_escape_state = 0;
            $netbar_matrix_input_escape_prefix = '';
            $netbar_matrix_input_escape_buffer = '';
            return;
        }

        if ($key >= 0x40 && $key <= 0x7e) {
            if ($netbar_matrix_input_escape_prefix eq '['
                && $netbar_matrix_input_escape_buffer eq '200~') {
                netbar_matrix_input_flush_pending()
                    if defined($netbar_matrix_input_current_key)
                        || @netbar_matrix_input_queue;
                $netbar_matrix_input_bracketed_paste = 1;
                $netbar_matrix_input_burst_count = 0;
                $netbar_matrix_input_burst_started = 0;
                $netbar_matrix_input_paste_until = 0;
            }
            elsif ($netbar_matrix_input_escape_prefix eq '['
                && $netbar_matrix_input_escape_buffer eq '201~') {
                $netbar_matrix_input_bracketed_paste = 0;
                $netbar_matrix_input_last_physical_time = 0;
                $netbar_matrix_input_burst_count = 0;
                $netbar_matrix_input_burst_started = 0;
                $netbar_matrix_input_paste_until = 0;
            }
            $netbar_matrix_input_escape_state = 0;
            $netbar_matrix_input_escape_prefix = '';
            $netbar_matrix_input_escape_buffer = '';
        }
        return;
    }

    # While a terminal explicitly marks bracketed paste, let Irssi consume the
    # bytes natively. No heuristic or animation is needed in this mode.
    return if $netbar_matrix_input_bracketed_paste;

    # Only ordinary visible one-cell ASCII letters/symbols get the Matrix flash.
    # Space and control keys stay native and immediate. If Enter/Backspace/etc.
    # arrives while letters are queued, commit the letters first so ordering is
    # never changed.
    if ($key < 33 || $key > 126) {
        netbar_matrix_input_flush_pending()
            if defined($netbar_matrix_input_current_key)
                || @netbar_matrix_input_queue;
        $netbar_matrix_input_last_physical_time = 0;
        $netbar_matrix_input_burst_count = 0;
        $netbar_matrix_input_burst_started = 0;
        $netbar_matrix_input_paste_until = 0;
        return;
    }

    # Fallback for terminals which do not expose bracketed paste to Irssi Perl.
    # Six printable bytes inside one 80 ms window is far beyond normal human
    # typing but still catches paste streams slowed down by SSH/terminal layers.
    if (Irssi::settings_get_bool('matrix_input_paste_bypass')) {
        my $now = time();
        if ($netbar_matrix_input_paste_until > $now) {
            $netbar_matrix_input_paste_until = $now + 0.150;
            $netbar_matrix_input_last_physical_time = $now;
            return;
        }

        $netbar_matrix_input_last_physical_time = $now;
        if (!$netbar_matrix_input_burst_started
            || ($now - $netbar_matrix_input_burst_started) * 1000.0 > 80.0) {
            $netbar_matrix_input_burst_started = $now;
            $netbar_matrix_input_burst_count = 1;
        }
        else {
            $netbar_matrix_input_burst_count++;
        }

        if ($netbar_matrix_input_burst_count >= 6) {
            netbar_matrix_input_flush_pending()
                if defined($netbar_matrix_input_current_key)
                    || @netbar_matrix_input_queue;
            $netbar_matrix_input_paste_until = $now + 0.150;
            $netbar_matrix_input_burst_count = 0;
            $netbar_matrix_input_burst_started = 0;
            return;
        }
    }

    # Bound pathological input even when paste detection is disabled. If the
    # queue fills, commit it synchronously rather than allocating indefinitely.
    if (@netbar_matrix_input_queue >= $netbar_matrix_input_max_queue) {
        netbar_matrix_input_flush_pending();
    }

    push @netbar_matrix_input_queue, { key => $key };

    # Stop Irssi from inserting this physical key now. It will be replayed after
    # its very short Matrix flash, preserving the exact original character.
    Irssi::signal_stop();
    netbar_matrix_input_start_next()
        unless defined $netbar_matrix_input_current_key;
}

sub netbar_matrix_input_manual_test_finish {
    undef $netbar_matrix_input_manual_test_tag;
    $netbar_matrix_input_overlay_active = 0;
    Irssi::statusbar_items_redraw('input');
}

sub netbar_matrix_input_manual_test {
    unless (netbar_matrix_input_effects_allowed()) {
        Irssi::print('Matrix: GNU Screen uses native input; raw glyph effects are paused.');
        return 0;
    }
    return unless -t STDERR;
    if ($netbar_matrix_input_manual_test_tag) {
        Irssi::timeout_remove($netbar_matrix_input_manual_test_tag);
        undef $netbar_matrix_input_manual_test_tag;
    }
    netbar_matrix_input_paint_glyph();
    $netbar_matrix_input_manual_test_tag = Irssi::timeout_add_once(
        80,
        'netbar_matrix_input_manual_test_finish',
        0,
    );
}

# Observe /JOIN (and the common /J abbreviation) without replacing Irssi's
# native command. The JOIN is still sent immediately; the Matrix overlay simply
# covers the terminal while the server completes it. At the end, the existing
# Matrix finish path focuses the joined channel when it already exists.
sub netbar_matrix_join_target {
    my ($data, $server, $witem) = @_;
    $data = '' unless defined $data;
    $data =~ s/^\s+|\s+$//g;
    return unless length $data;

    my @args = grep { length } split /\s+/, $data;
    my $target_server = $server;
    my $channel_token = '';

    while (@args) {
        my $arg = shift @args;
        if (lc($arg) eq '-server' && @args) {
            my $tag = shift @args;
            my $resolved = Irssi::server_find_tag($tag);
            $target_server = $resolved if $resolved;
            next;
        }
        if ($arg eq '--') {
            $channel_token = shift(@args) // '';
            last;
        }
        if ($arg =~ /^-(.+)/) {
            # Native /JOIN uses -<server tag>. Ignoring it animates/focuses a
            # same-named channel on the current connection after JOIN finishes.
            next if lc($arg) eq '-window' || lc($arg) eq '-invite';
            my $resolved = Irssi::server_find_tag($1);
            return unless $resolved; # let Irssi report an unknown option/tag
            $target_server = $resolved;
            next;
        }
        $channel_token = $arg;
        last;
    }

    return unless length $channel_token;
    my ($channel) = split /,/, $channel_token, 2;
    return unless defined($channel) && length($channel);
    return if $channel eq '0';
    return if $channel =~ /[\x00-\x20\x7f]/;

    $target_server ||= $witem->{server} if $witem && $witem->{server};
    $target_server ||= eval { Irssi::active_server() };
    return unless $target_server;

    my $is_channel = eval { $target_server->ischannel($channel) };
    $is_channel = ($channel =~ /^[#&+!]/) ? 1 : 0 if !defined $is_channel;
    return unless $is_channel;

    return ($target_server, $channel);
}

sub netbar_matrix_command_join_transition {
    my ($data, $server, $witem) = @_;
    return unless Irssi::settings_get_bool('matrix_enabled');
    return unless netbar_matrix_transition_mode() eq 'join';
    return if $netbar_matrix_running;

    my ($target_server, $channel) = netbar_matrix_join_target(
        $data, $server, $witem,
    );
    return unless $target_server && defined($channel) && length($channel);

    my $tag = netbar_server_tag($target_server);
    netbar_matrix_begin($tag, $channel);
}

sub netbar_matrix_startup_tick {
    undef $netbar_matrix_startup_timer_tag;

    # Empty destination means: show the same rain, then only redraw the window
    # which was already active. No channel, QUERY or server focus is changed.
    netbar_matrix_begin('', '');
}

sub netbar_matrix_schedule_startup {
    Irssi::timeout_remove($netbar_matrix_startup_timer_tag)
        if $netbar_matrix_startup_timer_tag;

    # Let the initial layout and its first 250 ms stabilisation pass complete
    # before temporarily covering the terminal with the Matrix overlay.
    $netbar_matrix_startup_timer_tag = Irssi::timeout_add_once(
        350,
        'netbar_matrix_startup_tick',
        0,
    );
}

sub netbar_command_matrix {
    my ($data) = @_;
    $data //= '';
    $data =~ s/^\s+|\s+$//g;

    if ($data eq '' || lc($data) eq 'status') {
        my $state = Irssi::settings_get_bool('matrix_enabled')
            ? 'ON' : 'OFF';
        my $mode = uc(netbar_matrix_transition_mode());
        my $screen_transition = Irssi::settings_get_bool('matrix_screen_transition_enabled')
            ? 'ON' : 'OFF';
        my $screen_input = Irssi::settings_get_bool('matrix_screen_input_enabled') ? 'ON' : 'OFF';
        my $duration = Irssi::settings_get_int('matrix_duration_ms');
        my $input_state = Irssi::settings_get_bool('matrix_input_enabled')
            ? 'ON' : 'OFF';
        my $input_flash = Irssi::settings_get_int('matrix_input_flash_ms');
        my $cursor_style = uc(netbar_matrix_cursor_style());
        Irssi::print(
            'Matrix: transition=' . $state . ', mode=' . $mode
            . ' (' . $duration . ' ms), screen-transition=' . $screen_transition
            . ', screen-input=' . $screen_input
            . ', startup=ON, input=' . $input_state
            . ' (instant glyph ' . $input_flash . ' ms), cursor='
            . $cursor_style . ' (solid green, no blink).'
            . (netbar_matrix_in_screen()
                ? ' GNU Screen: startup paused; input '
                    . ($screen_input eq 'ON' && $input_state eq 'ON' ? 'allowed' : 'paused')
                    . '; transitions '
                    . ($screen_transition eq 'ON' && $state eq 'ON' ? 'allowed.' : 'paused.')
                : '')
        );
        return;
    }

    if ($data =~ /^screen\s+input\s+(on|off|status)$/i) {
        my $argument = lc($1);
        if ($argument ne 'status') {
            my $enabled = $argument eq 'on' ? 1 : 0;
            Irssi::settings_set_bool('matrix_screen_input_enabled', $enabled);
            netbar_matrix_input_cancel() if !$enabled && netbar_matrix_in_screen();
        }
        Irssi::print('Matrix: screen-input='
            . (Irssi::settings_get_bool('matrix_screen_input_enabled') ? 'ON' : 'OFF')
            . '; follows input=' . (Irssi::settings_get_bool('matrix_input_enabled') ? 'ON' : 'OFF')
            . '. GNU Screen startup remains paused; transitions unchanged.');
        return;
    }

    if ($data =~ /^screen\s+(on|off|status)$/i) {
        my $argument = lc($1);
        if ($argument ne 'status') {
            my $enabled = $argument eq 'on' ? 1 : 0;
            Irssi::settings_set_bool('matrix_screen_transition_enabled', $enabled);
            netbar_matrix_cancel(1)
                if !$enabled && netbar_matrix_in_screen()
                    && $netbar_matrix_running && length($netbar_matrix_target_name);
        }
        Irssi::print('Matrix: screen-transition='
            . (Irssi::settings_get_bool('matrix_screen_transition_enabled') ? 'ON' : 'OFF')
            . '; follows transition=' . (Irssi::settings_get_bool('matrix_enabled') ? 'ON' : 'OFF')
            . ', mode=' . uc(netbar_matrix_transition_mode())
            . '. GNU Screen startup remains paused; input permission unchanged.');
        return;
    }

    if (lc($data) eq 'on') {
        Irssi::settings_set_bool('matrix_enabled', 1);
        Irssi::print(
            'Matrix: transitions enabled in ' . uc(netbar_matrix_transition_mode())
            . ' mode; startup and typing effects unchanged.'
        );
        return;
    }

    if (lc($data) eq 'off') {
        Irssi::settings_set_bool('matrix_enabled', 0);
        # Cancel only an in-progress channel/JOIN transition. An empty target is
        # the startup overlay and is deliberately not controlled by this switch.
        netbar_matrix_cancel(1)
            if $netbar_matrix_running && length($netbar_matrix_target_name);
        Irssi::print('Matrix: transitions disabled; selected mode, startup and typing effects remain unchanged.');
        return;
    }

    if (lc($data) eq 'window' || lc($data) eq 'join') {
        my $mode = lc($data);
        Irssi::settings_set_str('matrix_transition_mode', $mode);
        Irssi::settings_set_bool('matrix_enabled', 1);
        netbar_matrix_cancel(1)
            if $netbar_matrix_running && length($netbar_matrix_target_name);
        Irssi::print(
            $mode eq 'window'
                ? 'Matrix: WINDOW mode - animation when switching to an existing channel from the sidebar; /j and /join stay immediate. Typing effect unchanged.'
                : 'Matrix: JOIN mode - sidebar channel switching is immediate; animation runs only for /j and /join. Typing effect unchanged.'
        );
        return;
    }

    if ($data =~ /^duration\s+(\d+)$/i) {
        my $duration = int($1);
        if ($duration < 180 || $duration > 900) {
            Irssi::print('Matrix: duration must be between 180 and 900 ms.');
            return;
        }
        Irssi::settings_set_int('matrix_duration_ms', $duration);
        Irssi::print('Matrix: transition duration set to ' . $duration . ' ms.');
        return;
    }

    if ($data =~ /^cursor(?:\s+(block|underscore|_|status))?$/i) {
        my $argument = defined($1) ? lc($1) : '';
        my $style = netbar_matrix_cursor_style();

        if ($argument eq 'status') {
            Irssi::print(
                'Matrix cursor: ' . uc($style) . ', solid green, no blink.'
            );
            return;
        }

        if ($argument eq 'block') {
            $style = 'block';
        }
        elsif ($argument eq 'underscore' || $argument eq '_') {
            $style = 'underscore';
        }
        else {
            $style = $style eq 'block' ? 'underscore' : 'block';
        }

        Irssi::settings_set_str('matrix_cursor_style', $style);
        netbar_matrix_input_cursor_apply();
        Irssi::print(
            'Matrix cursor: '
            . ($style eq 'block' ? 'BLOCK' : 'UNDERSCORE (_)')
            . ', solid green, no blink.'
        );
        return;
    }

    if ($data =~ /^input\s+(on|off)$/i) {
        my $enabled = lc($1) eq 'on' ? 1 : 0;
        Irssi::settings_set_bool('matrix_input_enabled', $enabled);
        netbar_matrix_input_cancel() unless $enabled;
        Irssi::print('Matrix input glyph effect: ' . ($enabled ? 'enabled.' : 'disabled.')
            . (netbar_matrix_in_screen() && !netbar_matrix_input_effects_allowed()
                ? ' GNU Screen: native input; use /matrix screen input on to permit the effect.' : ''));
        return;
    }

    if ($data =~ /^input\s+test$/i) {
        unless (netbar_matrix_input_effects_allowed()) {
            netbar_matrix_input_manual_test();
            return;
        }
        netbar_matrix_input_manual_test();
        Irssi::print('Matrix input: instant glyph test shown at the cursor.');
        return;
    }

    if ($data =~ /^input\s+duration\s+(\d+)$/i) {
        my $flash = int($1);
        if ($flash < 20 || $flash > 120) {
            Irssi::print('Matrix input duration must be between 20 and 120 ms.');
            return;
        }
        Irssi::settings_set_int('matrix_input_flash_ms', $flash);
        Irssi::print('Matrix input glyph duration set to ' . $flash . ' ms.');
        return;
    }

    Irssi::print('Usage: /matrix [on|off|window|join|status|screen on|screen off|screen status|screen input on|screen input off|screen input status|duration 180-900|cursor|cursor block|cursor underscore|input on|input off|input test|input duration 20-120]');
}

# -------------------------------------------------------------------------
# Clickable network sidebar
# -------------------------------------------------------------------------

sub netbar_mouse_update_terminal_size {
    my ($columns, $rows);

    eval {
        require Term::ReadKey;
        ($columns, $rows) = Term::ReadKey::GetTerminalSize(*STDOUT);
    };

    if (!$columns || !$rows) {
        my $winsize = '';
        if (open my $tty, '+<', '/dev/tty') {
            if (ioctl($tty, 0x5413, $winsize)) {
                my ($r, $c) = unpack('S4', $winsize);
                $columns = $c if !$columns && $c;
                $rows = $r if !$rows && $r;
            }
            close $tty;
        }
    }

    $columns = int($ENV{COLUMNS})
        if !$columns && defined($ENV{COLUMNS}) && $ENV{COLUMNS} =~ /\A\d+\z/;
    $rows = int($ENV{LINES})
        if !$rows && defined($ENV{LINES}) && $ENV{LINES} =~ /\A\d+\z/;

    $columns ||= $netbar_mouse_screen_width || 0;
    $rows ||= $netbar_mouse_screen_height || 24;

    $columns = int($columns // 0);
    $rows = int($rows // 0);
    $columns = 0 if $columns < 1;
    $rows = 24 if $rows < 1;
    $columns = $MATRIX_MAX_TERMINAL_COLUMNS
        if $columns > $MATRIX_MAX_TERMINAL_COLUMNS;
    $rows = $MATRIX_MAX_TERMINAL_ROWS
        if $rows > $MATRIX_MAX_TERMINAL_ROWS;

    $netbar_mouse_screen_width = $columns if $columns >= 20;
    $netbar_mouse_screen_height = $rows;
    return $netbar_mouse_screen_height;
}

sub netbar_mouse_capture_timeout {
    undef $netbar_mouse_chat_click;
    undef $netbar_mouse_press_target;
    undef $netbar_mouse_drag;
    $netbar_mouse_timeout_tag = undef;
    $netbar_mouse_status = -1;
    $netbar_mouse_sgr_status = -1;
    $netbar_mouse_sgr_buffer = '';
    @netbar_mouse_sgr_press = ();
    $netbar_mouse_sgr_press_time = 0;
}

sub netbar_mouse_arm_capture_timeout {
    if ($netbar_mouse_timeout_tag) {
        Irssi::timeout_remove($netbar_mouse_timeout_tag);
    }
    $netbar_mouse_timeout_tag = Irssi::timeout_add_once(
        250,
        'netbar_mouse_capture_timeout',
        0,
    );
}

sub netbar_mouse_protocol {
    # SGR 1006 has decimal coordinates and therefore works past column/row 223.
    # VT200 remains enabled as a transparent fallback for older terminals.
    return 'sgr1006+vt200';
}

sub netbar_mouse_tracking_enable {
    return if $netbar_mouse_tracking || $netbar_mouse_binding_conflict;

    # Clear abandoned/old modes, enable normal press/release reporting, then ask
    # for SGR coordinates. A terminal which does not know 1006 simply continues
    # to send the legacy ESC [ M sequence handled by the fallback parser.
    print STDERR "\e[?9l\e[?1001l\e[?1002l\e[?1003l\e[?1005l\e[?1006l\e[?1015l\e[?1016l\e[?1000l\e[?1000h\e[?1006h";
    $netbar_mouse_tracking = 1;
}

sub netbar_mouse_tracking_rearm {
    return if $netbar_mouse_binding_conflict;
    return unless Irssi::settings_get_bool('matrix_mouse_enabled');

    # Some SSH terminals drop private mouse modes while their viewport is being
    # resized. Re-send the modes without changing the user's ON/OFF setting.
    print STDERR "\e[?9l\e[?1001l\e[?1002l\e[?1003l\e[?1005l\e[?1006l\e[?1015l\e[?1016l\e[?1000l\e[?1000h\e[?1006h";
    $netbar_mouse_tracking = 1;
}

sub netbar_mouse_tracking_disable {
    undef $netbar_mouse_chat_click;
    undef $netbar_mouse_press_target;
    undef $netbar_mouse_drag;
    return unless $netbar_mouse_tracking;
    print STDERR "\e[?1016l\e[?1015l\e[?1006l\e[?1005l\e[?1003l\e[?1002l\e[?1001l\e[?1000l\e[?9l";
    $netbar_mouse_tracking = 0;
    $netbar_mouse_status = -1;
    $netbar_mouse_sgr_status = -1;
    $netbar_mouse_sgr_buffer = '';
    @netbar_mouse_sgr_press = ();
    $netbar_mouse_sgr_press_time = 0;
}

sub netbar_mouse_apply_setting {
    return unless $netbar_mouse_installed;
    if (Irssi::settings_get_bool('matrix_mouse_enabled')) {
        netbar_mouse_tracking_enable();
    }
    else {
        netbar_mouse_tracking_disable();
    }
}

sub netbar_mouse_command_xterm {
    return unless Irssi::settings_get_bool('matrix_mouse_enabled');

    $netbar_mouse_sgr_status = -1;
    $netbar_mouse_sgr_buffer = '';
    $netbar_mouse_status = 0;
    netbar_mouse_arm_capture_timeout();
}

sub netbar_mouse_command_sgr {
    return unless Irssi::settings_get_bool('matrix_mouse_enabled');

    $netbar_mouse_status = -1;
    $netbar_mouse_sgr_status = 0;
    $netbar_mouse_sgr_buffer = '';
    netbar_mouse_arm_capture_timeout();
}

sub netbar_mouse_resolve_item {
    my ($hitbox) = @_;
    return undef unless $hitbox;

    my $tag = $hitbox->{tag} // '';
    my $name = $hitbox->{name} // '';
    return undef unless length($tag) && length($name);

    my $server = Irssi::server_find_tag($tag);
    return undef unless $server && $server->{connected};

    my $connection = $hitbox->{connection} // '';
    return undef unless length($connection)
        && matrix_connection_identity($server) eq $connection;

    if (($hitbox->{type} // '') eq 'CHANNEL') {
        my $channel = eval { $server->channel_find($name) };
        return $channel if $channel && $channel->{joined};
    }
    elsif (($hitbox->{type} // '') eq 'QUERY') {
        return eval { $server->query_find($name) };
    }

    return undef;
}

sub netbar_mouse_sync_layout_for_click {
    netbar_mouse_update_terminal_size();

    my $geometry_changed = 0;
    $geometry_changed = 1
        if $netbar_mouse_layout_width > 0
        && $netbar_mouse_screen_width > 0
        && $netbar_mouse_layout_width != $netbar_mouse_screen_width;
    $geometry_changed = 1
        if $netbar_mouse_layout_height > 0
        && $netbar_mouse_screen_height > 0
        && $netbar_mouse_layout_height != $netbar_mouse_screen_height;

    # A queued sidebar refresh can also make old hitboxes point at the wrong
    # channel after JOIN/PART/PM expansion. Resolve that before accepting a click.
    if ($geometry_changed || $force_layout || defined($refresh_timer)) {
        if (defined $refresh_timer) {
            Irssi::timeout_remove($refresh_timer);
            undef $refresh_timer;
        }
        $force_layout = 1 if $geometry_changed;
        netbar_refresh_all();
    }

    return $netbar_mouse_screen_height;
}

sub netbar_mouse_sync_tracking {
    netbar_mouse_apply_setting();
}

sub netbar_mouse_focus_network {
    my ($network) = @_;
    return 0 unless defined($network) && length($network);
    my $item = netbar_resolve_remembered_item($network)
        || netbar_fallback_item_for_network($network);
    return 1 if $item && netbar_focus_item($item);
    return netbar_focus_network_status($network);
}

sub netbar_mouse_sidebar_hitbox_at {
    my ($x, $y) = @_;
    return undef unless defined($x) && defined($y);
    return undef unless $netbar_sidebar_mouse_width > 1
        && $netbar_sidebar_mouse_height > 0;
    return undef if $x < 0 || $y < 0;
    return undef if $x >= $netbar_sidebar_mouse_width - 1;
    return undef if $y >= $netbar_sidebar_mouse_height;

    my $sidebar = netbar_sidebar_window();
    return undef unless $sidebar;
    return undef unless int($sidebar->{width} // -1)
        == $netbar_sidebar_mouse_width;
    return undef unless int($sidebar->{height} // -1)
        == $netbar_sidebar_mouse_height;

    my $target = $netbar_sidebar_mouse_rows[$y];
    return undef unless $target;
    my $kind = $target->{mouse_kind} // '';
    return undef unless length($kind);

    my $start = int($target->{mouse_x_start} // 0);
    my $end = int($target->{plain_length} // 0) - 1;
    $end = $netbar_sidebar_mouse_width - 2
        if $end >= $netbar_sidebar_mouse_width - 1;
    return undef if $x < $start || $x > $end;

    if ($kind eq 'channel') {
        return {
            type       => 'CHANNEL',
            tag        => $target->{mouse_tag} // '',
            name       => $target->{mouse_name} // '',
            connection => $target->{mouse_connection} // '',
        };
    }
    if ($kind eq 'status') {
        return {
            type    => 'STATUS',
            network => $target->{mouse_network} // '',
        };
    }
    if ($kind eq 'network') {
        return {
            type    => 'NETWORK',
            network => $target->{mouse_network} // '',
        };
    }
    if ($kind eq 'query') {
        return {
            type       => 'QUERY',
            tag        => $target->{mouse_tag} // '',
            name       => $target->{mouse_name} // '',
            connection => $target->{mouse_connection} // '',
        };
    }
    if ($kind eq 'pm_page') {
        return { type => 'PM_PAGE', network => $target->{mouse_network},
            direction => $target->{mouse_direction} };
    }
    if ($kind eq 'overflow') {
        return {
            type      => 'SCROLL',
            direction => $target->{mouse_direction} // '',
        };
    }
    return undef;
}

# Coordinates already exclude the terminal's one-based wire origin. Offset 2
# is the historical/default calibration; increasing N selects N-2 rows lower.
sub netbar_mouse_calibrated_y {
    my ($y) = @_;
    return undef unless defined $y;
    my $offset = Irssi::settings_get_int('matrix_mouse_offset');
    $offset = 2 if $offset < 0 || $offset > 20;
    return $y + $offset - 2;
}


# Resolve only the conversation pane owned by MATRIX. Irssi 1.4's Perl API
# exposes view dimensions, but not split origins/statusbar placement. Require
# the standard two-pane geometry (topic+gap, five footer rows); fail closed if
# custom splits/bars no longer match it, including /netbars off.
sub netbar_mouse_chat_nick_at {
    my ($x, $y) = @_;
    return undef if $matrix_unloading || $netbar_matrix_running;
    return undef unless defined($x) && defined($y)
        && Irssi::settings_get_bool('matrix_mouse_enabled')
        && Irssi::settings_get_bool('network_statusbars_enabled')
        && $netbar_sidebar_chrome_ready;
    my $window = Irssi::active_win();
    my $sidebar = netbar_sidebar_window();
    return undef unless $window && $sidebar && !netbar_sidebar_is_window($window);
    my $channel = $window->{active};
    return undef unless $channel && ($channel->{type} // '') eq 'CHANNEL';
    my $left = int($sidebar->{width} // 0) + 1; # native split divider
    return undef unless $left > 1
        && int($sidebar->{height} // 0) == $netbar_mouse_screen_height
        && $left + int($window->{width} // 0) == $netbar_mouse_screen_width
        && int($window->{height} // 0) + 7 == $netbar_mouse_screen_height;
    my $col = $x - $left;
    my $row = $y - 2;
    return undef if $col < 0 || $col >= $window->{width}
        || $row < 0 || $row >= $window->{height};

    # Everything below is synchronous and read-only. Drop all native wrappers
    # on return, BEFORE a query can be created or focused by the caller.
    my $view = eval { $window->view() };
    return undef unless $view && $view->{startline};
    my $line = $view->{startline};
    my $subline = int($view->{subline} // 0);
    my $scanned = 0;
    while ($line && ++$scanned <= 4096) {
        unless (($line->{info}->{level} // 0) & ($view->{hidden_level} // 0)) {
            my $cache = $view->get_line_cache($line);
            return undef unless $cache && $cache->{count} > 0;
            # /CLEAR may leave startline fully above the visible view.
            # Native view_line_draw skips it when subline >= cache count.
            my $rows = $cache->{count} - $subline;
            $rows = 0 if $rows < 0;
            if ($row < $rows) {
                # The author is only on the first physical row; never match a
                # nick mentioned in a wrapped continuation or the message body.
                return undef unless $row == 0 && $subline == 0;
                last;
            }
            $row -= $rows;
        }
        $subline = 0;
        $line = $line->next();
    }
    return undef unless $line && $scanned <= 4096;
    my $plain = matrix_decode_valid_utf8($line->get_text(0));
    my $format = eval { $line->get_format() };

    # Try /NAMES before requiring public-message metadata. Irssi 1.4.5 may
    # retain line-format metadata for a rendered NAMES row depending on the
    # print path/theme, so treating only completely unformatted lines as
    # NAMES is too strict.
    my $names_hit = netbar_mouse_names_nick_on_line(
        $window, $channel, $line, $col, $left, $view, $y, $plain, $format,
    );
    return $names_hit if $names_hit;

    return undef unless $format && ($format->{module} // '') eq 'fe-common/core'
        && ($format->{format} // '') =~ /\Apubmsg(?:_me|_hilight)?(?:_channel)?\z/;
    my $server = Irssi::server_find_tag($format->{server_tag} // '');
    return undef unless $server && $server->{connected};
    my $target = $format->{target} // '';
    my $source = $server->channel_find($target);
    return undef unless $source && $source->{joined};
    my $source_window = $source->window();
    return undef unless $source_window && $source_window->{_irssi} eq $window->{_irssi};
    my $nick = $format->{nick} // '';
    return undef unless length($nick) && $nick !~ /[\s\x00-\x1f\x7f,]/;
    return undef if matrix_irc_casefold($server, $nick)
        eq matrix_irc_casefold($server, $server->{nick});

    my @args = @{$format->{args} || []};
    my $nick_arg = $format->{format} =~ /_hilight/ ? 1 : 0;
    my $display_nick = matrix_decode_valid_utf8(Irssi::strip_codes($args[$nick_arg] // ''));
    return undef unless length($display_nick);
    my $render = sub {
        return matrix_decode_valid_utf8(Irssi::strip_codes($window->format_get_text(
            $format->{module}, $server, $target, $format->{format}, @_)));
    };
    my $body = $render->(@args);
    # Match the exact rendered message even when a theme adds a suffix.
    # Some 1.4.5 builds store </nick>/ while format_get_text returns <nick>.
    # Accept only that exact alternate framing and a unique full-body match.
    my $body_at = length($body) ? index($plain, $body) : -1;
    my $visible_body = $body;
    my $nick_extra_offset = 0;
    if ($body_at < 0 && $body =~ /\A<[^>]*\Q$display_nick\E> /) {
        my $decorated = $body;
        $decorated =~ s/\A</<\//;
        $decorated =~ s/>/>\//;
        my $at = index($plain, $decorated);
        if ($at >= 0) {
            $body_at = $at;
            $visible_body = $decorated;
            $nick_extra_offset = 1;
        }
    }
    return undef if $body_at < 0
        || index($plain, $visible_body, $body_at + 1) >= 0;

    # Render an equal-cell-width replacement author to locate the nick through
    # the current theme/alignment. Verify that ONLY that exact span changes.
    # This avoids guessing timestamp widths, mode prefixes or nick colours.
    my ($marker) = grep { index($display_nick, $_) < 0 } qw(~ ^ ! ? X Z);
    return undef unless defined $marker;
    $marker x= matrix_text_width($display_nick);
    return undef unless length($marker);
    $args[$nick_arg] = $marker;
    my $marked = $render->(@args);
    my $pos = 0;
    $pos++ while $pos < length($body) && $pos < length($marked)
        && substr($body, $pos, 1) eq substr($marked, $pos, 1);
    return undef unless substr($body, $pos, length($display_nick)) eq $display_nick
        && $marked eq substr($body, 0, $pos) . $marker
            . substr($body, $pos + length($display_nick));
    $pos += $body_at + $nick_extra_offset;
    my $start = matrix_text_width(substr($plain, 0, $pos));
    my $end = $start + matrix_text_width($display_nick);
    return undef if $col < $start || $col >= $end || $end > $view->{width};
    return {
        type => 'CHAT_NICK', tag => $server->{tag}, name => $nick,
        chat_key => join("\0", matrix_connection_identity($server),
            $window->{_irssi}, $view->{buffer}->{_irssi}, $line->{_irssi},
            $line->{info}->{time}, $plain, $view->{startline}->{_irssi},
            $view->{subline}, $left, $view->{width}, $view->{height}, $y),
        connection => matrix_connection_identity($server),
    };
}

# /NAMES is a real multi-column grid.  Irssi sizes every column to the
# widest rendered names_nick* cell in that column and then pads shorter cells.
# Textbuffer get_text() is useful for identifying the nick tokens, but its
# character offsets after the first padded column are not guaranteed to be the
# screen X coordinates.  Reconstruct the grid from the contiguous /NAMES block
# and hit-test against the same theme fragments Irssi uses to render each cell.
sub netbar_mouse_names_record_map {
    my ($channel) = @_;
    my $server = $channel ? $channel->{server} : undef;
    return undef unless $server;

    my @records = eval { $channel->nicks() };
    return undef if $@ || !@records;

    my %record_by_fold;
    for my $record (@records) {
        next unless $record;
        my $nick = matrix_terminal_plain_text($record->{nick} // '');
        next unless length($nick) && $nick !~ /[\s\x00-\x1f\x7f,]/;
        $record_by_fold{matrix_irc_casefold($server, $nick)} = [$record, $nick];
    }
    return keys(%record_by_fold) ? \%record_by_fold : undef;
}

sub netbar_mouse_names_visible_tokens {
    my ($server, $plain, $record_by_fold) = @_;
    return () unless $server && defined($plain) && length($plain)
        && $record_by_fold && ref($record_by_fold) eq 'HASH';

    my @visible;
    # Longest first makes an overlap deterministic if one legal nick happens to
    # be a textual prefix/suffix of another legal nick.
    for my $pair (sort { length($b->[1]) <=> length($a->[1]) }
            values %$record_by_fold) {
        my ($record, $nick) = @$pair;
        my $from = 0;
        while (1) {
            my $at = index($plain, $nick, $from);
            last if $at < 0;

            my $before = $at > 0 ? substr($plain, $at - 1, 1) : '';
            my $after_at = $at + length($nick);
            my $after = $after_at < length($plain)
                ? substr($plain, $after_at, 1) : '';
            my $left_ok = $at == 0 || $before =~ /[\s\[\]@%+&~]/;
            my $right_ok = $after_at >= length($plain) || $after =~ /[\s\[\]]/;
            if ($left_ok && $right_ok) {
                my $overlap = 0;
                for my $old (@visible) {
                    my $old_end = $old->{at} + length($old->{nick});
                    $overlap = 1 if $at < $old_end && $after_at > $old->{at};
                    last if $overlap;
                }
                push @visible, { record => $record, nick => $nick, at => $at }
                    unless $overlap;
            }
            $from = $at + 1;
        }
    }

    return sort { $a->{at} <=> $b->{at} } @visible;
}

sub netbar_mouse_names_line_is_row {
    my ($server, $plain, $format_name, $visible) = @_;
    return 0 unless $server && defined($plain) && $visible && @$visible;
    return 0 if ($format_name // '')
        =~ /\Apubmsg(?:_me|_hilight)?(?:_channel)?\z/;

    my %seen_fold;
    $seen_fold{matrix_irc_casefold($server, $_->{nick})} = 1 for @$visible;
    return 1 if ($format_name // '') =~ /\Anames(?:_|\z)/;
    return 1 if scalar(keys %seen_fold) >= 2;

    # A final /NAMES row may contain only one nick.  Require the active theme's
    # visible [ ... ] cell framing so ordinary one-nick notices do not become
    # clickable merely because they mention somebody currently on the channel.
    my $v = $visible->[0];
    my $before = substr($plain, 0, $v->{at});
    my $after = substr($plain, $v->{at} + length($v->{nick}));
    return $before =~ /\[\s*(?:[@%+&~]+\s*)?\z/ && $after =~ /\A\s*\]/ ? 1 : 0;
}

sub netbar_mouse_names_cell_geometry {
    my ($window, $channel, $record, $nick) = @_;
    return undef unless $window && $channel && $record && defined($nick) && length($nick);
    my $server = $channel->{server};
    return undef unless $server;

    # Irssi supplies a mode/prefix field to names_nick*.  Prefer the explicit
    # prefixes string (it also covers multi-prefix configurations), then fall
    # back to the traditional op/halfop/voice flags used by Irssi 1.4.x.
    my $nickmode = matrix_terminal_plain_text($record->{prefixes} // '');
    if (!length($nickmode)) {
        $nickmode = $record->{op} ? '@'
            : $record->{halfop} ? '%'
            : $record->{voice} ? '+' : ' ';
    }

    my $format_name = $record->{op} ? 'names_nick_op'
        : $record->{halfop} ? 'names_nick_halfop'
        : $record->{voice} ? 'names_nick_voice'
        : 'names_nick';

    my $render = sub {
        my ($shown) = @_;
        my $text = eval { $window->format_get_text(
            'fe-common/core', $server, $channel->{name},
            $format_name, $nickmode, $shown,
        ) };
        return undef if $@ || !defined $text;
        return matrix_decode_valid_utf8(Irssi::strip_codes($text));
    };

    my $base = $render->($nick);
    return undef unless defined($base) && length($base);

    # Replace only the nickname argument with an equal display-width ASCII
    # marker.  The single changed span reveals the nickname's exact offset
    # inside the rendered [mode nick] cell without assuming a specific theme.
    my ($marker_char) = grep { index($nick, $_) < 0 } qw(~ ^ ! ? X Z Q);
    return undef unless defined $marker_char;
    my $marker = $marker_char x matrix_text_width($nick);
    return undef unless length($marker);
    my $marked = $render->($marker);
    return undef unless defined($marked);

    my $pos = 0;
    $pos++ while $pos < length($base) && $pos < length($marked)
        && substr($base, $pos, 1) eq substr($marked, $pos, 1);
    return undef unless substr($base, $pos, length($nick)) eq $nick
        && $marked eq substr($base, 0, $pos) . $marker
            . substr($base, $pos + length($nick));

    return {
        width       => matrix_text_width($base),
        nick_offset => matrix_text_width(substr($base, 0, $pos)),
        nick_width  => matrix_text_width($nick),
    };
}

sub netbar_mouse_names_row_data {
    my ($window, $channel, $line, $record_by_fold, $known_plain, $known_format) = @_;
    return undef unless $window && $channel && $line && $record_by_fold;
    my $server = $channel->{server};
    return undef unless $server;

    my $plain = defined($known_plain)
        ? $known_plain : matrix_decode_valid_utf8($line->get_text(0));
    return undef unless defined($plain) && length($plain);
    my $line_format = defined($known_format)
        ? $known_format : eval { $line->get_format() };
    my $format_name = $line_format ? ($line_format->{format} // '') : '';

    my @visible = netbar_mouse_names_visible_tokens(
        $server, $plain, $record_by_fold,
    );
    return undef unless netbar_mouse_names_line_is_row(
        $server, $plain, $format_name, \@visible,
    );

    for my $v (@visible) {
        $v->{geometry} = netbar_mouse_names_cell_geometry(
            $window, $channel, $v->{record}, $v->{nick},
        );
        return undef unless $v->{geometry};
    }

    return {
        line => $line, plain => $plain, visible => \@visible,
        format_name => $format_name,
    };
}

sub netbar_mouse_names_nick_on_line {
    my ($window, $channel, $line, $col, $left, $view, $y, $plain, $line_format) = @_;
    return undef unless $window && $channel && $line && $view;

    my $server = $channel->{server};
    return undef unless $server && $server->{connected} && $channel->{joined};
    return undef unless defined($plain) && length($plain);

    my $record_by_fold = netbar_mouse_names_record_map($channel);
    return undef unless $record_by_fold;

    my $current = netbar_mouse_names_row_data(
        $window, $channel, $line, $record_by_fold, $plain, $line_format,
    );
    return undef unless $current;

    # Collect the contiguous /NAMES block around the clicked row.  Column widths
    # are determined by the widest cell in each column across the whole block,
    # which is exactly why a direct text offset worked for column 1 but drifted
    # for columns 2..N in 5.1.51.
    my @rows = ($current);
    my $probe = $line->prev();
    my $guard = 0;
    while ($probe && ++$guard <= 256) {
        my $row = netbar_mouse_names_row_data(
            $window, $channel, $probe, $record_by_fold, undef, undef,
        );
        last unless $row;
        unshift @rows, $row;
        $probe = $probe->prev();
    }

    $probe = $line->next();
    $guard = 0;
    while ($probe && ++$guard <= 256) {
        my $row = netbar_mouse_names_row_data(
            $window, $channel, $probe, $record_by_fold, undef, undef,
        );
        last unless $row;
        push @rows, $row;
        $probe = $probe->next();
    }

    my @column_width;
    for my $row (@rows) {
        my $visible = $row->{visible};
        for my $i (0 .. $#$visible) {
            my $width = $visible->[$i]->{geometry}->{width};
            $column_width[$i] = $width
                if !defined($column_width[$i]) || $width > $column_width[$i];
        }
    }

    my $visible = $current->{visible};
    return undef unless @$visible && defined $column_width[0];

    # The first column never has preceding column padding, so its nick position
    # in get_text() is a reliable anchor.  Move left to the start of cell 0,
    # then walk the reconstructed per-column widths for columns 1..N.
    my $first = $visible->[0];
    my $first_direct_start = matrix_text_width(
        substr($plain, 0, $first->{at}),
    );
    my $grid_start = $first_direct_start - $first->{geometry}->{nick_offset};
    return undef if $grid_start < 0;

    my $cell_start = $grid_start;
    for my $i (0 .. $#$visible) {
        my $v = $visible->[$i];
        my $geometry = $v->{geometry};
        my $start = $cell_start + $geometry->{nick_offset};
        my $end = $start + $geometry->{nick_width};

        if ($col >= $start && $col < $end && $end <= $view->{width}) {
            my $nick = $v->{nick};
            return undef if matrix_irc_casefold($server, $nick)
                eq matrix_irc_casefold($server, $server->{nick} // '');

            return {
                type => 'CHAT_NICK', tag => $server->{tag}, name => $nick,
                chat_key => join("\0", 'NAMES', matrix_connection_identity($server),
                    $window->{_irssi}, $view->{buffer}->{_irssi}, $line->{_irssi},
                    $line->{info}->{time}, $plain, $view->{startline}->{_irssi},
                    $view->{subline}, $left, $view->{width}, $view->{height}, $y,
                    $i, $nick),
                connection => matrix_connection_identity($server),
            };
        }

        return undef unless defined $column_width[$i] && $column_width[$i] > 0;
        $cell_start += $column_width[$i];
    }

    return undef;
}

sub netbar_mouse_chat_open_query {
    my ($hitbox) = @_;
    my $server = Irssi::server_find_tag($hitbox->{tag});
    return unless $server && $server->{connected}
        && matrix_connection_identity($server) eq $hitbox->{connection};
    my $query = $server->query_find($hitbox->{name});
    unless ($query) {
        # /QUERY is available even when this build has no query_create XS
        # export. Never use /MSG: opening a conversation must send no message.
        my $tag = $server->{tag} // '';
        my $nick = $hitbox->{name} // '';
        return unless $tag =~ /\A[A-Za-z0-9_.:+-]+\z/
            && $nick =~ /\A[^\s\x00-\x1f\x7f,;]+\z/
            && $nick !~ /\A-/;
        return unless eval { Irssi::command('^query -' . $tag . ' ' . $nick); 1 };
        $server = Irssi::server_find_tag($hitbox->{tag});
        return unless $server && $server->{connected}
            && matrix_connection_identity($server) eq $hitbox->{connection};
        $query = $server->query_find($hitbox->{name});
    }
    netbar_focus_item($query) if $query;
}

sub netbar_mouse_hitbox_at {
    my ($x, $y) = @_;
    netbar_mouse_sync_layout_for_click();
    $y = netbar_mouse_calibrated_y($y);
    return netbar_mouse_sidebar_hitbox_at($x, $y)
        || netbar_mouse_chat_nick_at($x, $y);
}

sub netbar_mouse_left_click {
    my ($hitbox) = @_;
    return unless $hitbox;

    if (($hitbox->{type} // '') eq 'STATUS') {
        netbar_focus_network_status($hitbox->{network});
        return;
    }

    if (($hitbox->{type} // '') eq 'NETWORK') {
        netbar_mouse_focus_network($hitbox->{network});
        return;
    }

    if (($hitbox->{type} // '') eq 'PM_PAGE') {
        matrix_pm_scroll_network($hitbox->{network}, $hitbox->{direction});
        return;
    }

    if (($hitbox->{type} // '') eq 'SCROLL') {
        my $direction = $hitbox->{direction} // '';
        if ($direction eq 'up') {
            netbar_sidebar_scroll_rows(-1);
        }
        elsif ($direction eq 'down') {
            netbar_sidebar_scroll_rows(1);
        }
        return;
    }

    my $item = netbar_mouse_resolve_item($hitbox);
    if ($item) {
        # Matrix is intentionally a mouse-only channel-to-channel transition.
        # Returning true means the target will be focused after the last frame.
        return if netbar_matrix_start_for_item($item);
        netbar_focus_item($item);
    }
    else {
        netbar_schedule_refresh();
    }
}

sub netbar_mouse_right_click {
    my ($hitbox) = @_;
    return unless $hitbox;

    return unless ($hitbox->{type} // '') eq 'CHANNEL'
        || ($hitbox->{type} // '') eq 'QUERY';

    if (!$hitbox->{immediate_close}
        && Irssi::settings_get_bool('matrix_mouse_confirm_close')) {
        my $key = join("\0",
            $hitbox->{type} // '',
            $hitbox->{tag} // '',
            $hitbox->{name} // '',
            $hitbox->{connection} // '',
        );
        my $now = time();
        if ($netbar_mouse_pending_close_key ne $key
            || $netbar_mouse_pending_close_until < $now) {
            $netbar_mouse_pending_close_key = $key;
            $netbar_mouse_pending_close_until = $now + 1.5;
            Irssi::print(
                'MATRIX: repeat right-click within 1.5 s to close the selected window.',
                Irssi::MSGLEVEL_CLIENTNOTICE(),
            );
            return;
        }
        $netbar_mouse_pending_close_key = '';
        $netbar_mouse_pending_close_until = 0;
    }

    my $item = netbar_mouse_resolve_item($hitbox);
    if ($item && netbar_focus_item($item)) {
        Irssi::command('^wc');
        netbar_schedule_refresh();
    }
    else {
        netbar_schedule_refresh();
    }
}

sub netbar_mouse_begin_drag {
    my ($button, $x, $y) = @_;
    undef $netbar_mouse_drag;
    undef $netbar_mouse_press_target;
    if ($netbar_matrix_running) {
        undef $netbar_mouse_chat_click;
        return;
    }
    return unless Irssi::settings_get_bool('matrix_mouse_enabled');
    my $hitbox = netbar_mouse_hitbox_at($x, $y);
    undef $netbar_mouse_chat_click
        unless $button == 0 && $hitbox && ($hitbox->{type} // '') eq 'CHAT_NICK';
    $netbar_mouse_press_target = { %$hitbox } if $hitbox;
    return unless $button == 0;
    return unless $hitbox && ($hitbox->{type} // '') eq 'CHANNEL';
    my $channel = netbar_mouse_resolve_item($hitbox);
    my $window = $channel ? eval { $channel->window() } : undef;
    return unless $window && $window->{refnum} > 1;
    $netbar_mouse_drag = {
        hitbox => { %$hitbox }, refnum => int($window->{refnum}),
        connection => $hitbox->{connection} // '',
        x => $x, y => $y, started => time(),
    };
}

sub netbar_mouse_drop_channel {
    my ($drag, $target) = @_;
    return unless $drag && $target && ($target->{type} // '') eq 'CHANNEL';
    return unless time() - $drag->{started} <= 15;
    my $source_item = netbar_mouse_resolve_item($drag->{hitbox});
    my $target_item = netbar_mouse_resolve_item($target);
    return unless $source_item && $target_item;
    # Resolve live connection identities before any refnum/history/save change.
    # Never swap channels across server connections, even if names match.
    my $source_identity = matrix_connection_identity($source_item->{server});
    my $target_identity = matrix_connection_identity($target_item->{server});
    return unless length($drag->{connection} // '')
        && $source_identity eq $drag->{connection}
        && length($target->{connection} // '')
        && $target_identity eq $target->{connection}
        && $source_identity eq $target_identity;
    my $source_tag = netbar_server_tag($source_item->{server});
    my $target_tag = netbar_server_tag($target_item->{server});
    return unless length($source_tag) && length($target_tag)
        && lc($source_tag) eq lc($target_tag);
    my $source_win = eval { $source_item->window() };
    my $target_win = eval { $target_item->window() };
    return unless $source_win && $target_win;
    my $source_num = int($source_win->{refnum});
    my $target_num = int($target_win->{refnum});
    return unless $source_num == $drag->{refnum} && $source_num > 1 && $target_num > 1;
    return if $source_num == $target_num;
    # Irssi swaps occupied numbers atomically. No intermediate focus change,
    # JOIN/PART, renumbering of other windows, or clearing of unread activity.
    $source_win->set_refnum($target_num);
    # Store names and expected numbers, never stale native object pointers.
    # Refnum changes invalidate the preceding record synchronously above.
    $netbar_channel_swap_undo = {
        source => { %{$drag->{hitbox}} }, target => { %$target },
        source_num => $source_num, target_num => $target_num,
    };
    $netbar_mouse_pending_close_key = '';
    $netbar_mouse_pending_close_until = 0;
    netbar_refresh_all();
    matrix_redraw_prompt_context();
    netbar_channel_order_save();
}

sub netbar_channel_order_save {
    return unless Irssi::settings_get_bool('matrix_order_autosave');
    # Use Irssi's native persistence, including channel-to-window bindings.
    # Like a manual /save, this commits the current configuration as well.
    Irssi::command('^layout save');
    Irssi::command('^save');
}

sub netbar_channel_swap_invalidate {
    undef $netbar_channel_swap_undo;
}

sub netbar_navigation_notice {
    my ($message) = @_;
    my $window = Irssi::active_win();
    $window = netbar_sidebar_content_window()
        if $window && netbar_sidebar_is_window($window);
    if ($window) {
        $window->print($message, Irssi::MSGLEVEL_CLIENTCRAP());
    }
    else {
        Irssi::print($message, Irssi::MSGLEVEL_CLIENTCRAP());
    }
}

sub netbar_command_undo {
    my ($data) = @_;
    if (defined($data) && $data =~ /\S/) {
        netbar_navigation_notice('MATRIX: /matrixundo - undo the last channel swap made with the mouse.');
        return;
    }
    my $undo = $netbar_channel_swap_undo;
    unless ($undo) {
        netbar_navigation_notice('MATRIX: no swap to undo (or the layout has changed).');
        return;
    }
    undef $netbar_channel_swap_undo;
    my $source = netbar_mouse_resolve_item($undo->{source});
    my $target = netbar_mouse_resolve_item($undo->{target});
    my $sw = $source ? eval { $source->window() } : undef;
    my $tw = $target ? eval { $target->window() } : undef;
    unless ($sw && $tw && $sw->{refnum} == $undo->{target_num}
            && $tw->{refnum} == $undo->{source_num}) {
        netbar_navigation_notice('MATRIX: layout changed; this swap cannot be undone.');
        return;
    }
    $sw->set_refnum($undo->{source_num});
    $netbar_mouse_pending_close_key = '';
    $netbar_mouse_pending_close_until = 0;
    netbar_refresh_all();
    matrix_redraw_prompt_context();
    netbar_channel_order_save();
    netbar_navigation_notice('MATRIX: last channel swap undone.');
}

sub netbar_command_find {
    my ($data) = @_;
    $data //= '';
    $data =~ s/^\s+|\s+$//g;
    unless (length $data) {
        netbar_navigation_notice('MATRIX: /matrixfind fragment | network/name | number - find channels and PMs.');
        return;
    }
    my $needle = lc $data;
    my (@matches, @exact);
    for my $item (Irssi::channels(), Irssi::queries()) {
        next unless $item && $item->{server} && length($item->{name} // '');
        next if netbar_item_kind($item) eq 'CHANNEL' && !$item->{joined};
        my $window = eval { $item->window() };
        next unless $window && !netbar_sidebar_is_window($window);
        my $name = $item->{name};
        my $tag = netbar_server_tag($item->{server});
        my $network = netbar_network_name_for_server($item->{server});
        my @labels = map { lc $_ } ($name, "$tag/$name", "$network/$name");
        my $entry = { item => $item, number => int($window->{refnum}),
                      label => "$tag/$name" };
        if ($needle =~ /^\d+$/) {
            push @exact, $entry if $entry->{number} == $needle;
        }
        else {
            push @exact, $entry if grep { $_ eq $needle } @labels;
            push @matches, $entry if grep { index($_, $needle) >= 0 } @labels;
        }
    }
    my @found = sort { $a->{number} <=> $b->{number}
                          || $a->{label} cmp $b->{label} } (@exact ? @exact : @matches);
    unless (@found) {
        netbar_navigation_notice('MATRIX: no matching channel or PM found.');
        return;
    }
    if (@found == 1) {
        netbar_navigation_notice('MATRIX: conversation is no longer available.')
            unless netbar_focus_item($found[0]->{item});
        return;
    }
    netbar_navigation_notice('MATRIX: matches: ' . scalar(@found)
        . ' conversations. Use /matrixfind network/name or /matrixfind number:');
    # Literal substring matching: user input is never evaluated as regex/code.
    for my $entry (@found) {
        netbar_navigation_notice('  ' . $entry->{number} . '  ' . $entry->{label});
    }
}

sub netbar_mouse_event {
    my ($button, $x, $y, $old_button, $old_x, $old_y) = @_;

    my $chat_click = $netbar_mouse_chat_click;
    undef $netbar_mouse_chat_click;
    my $drag = $netbar_mouse_drag;
    undef $netbar_mouse_drag;
    my $pressed = $netbar_mouse_press_target;
    undef $netbar_mouse_press_target;

    # The Matrix overlay temporarily hides the sidebar. Never resolve a click
    # against invisible rows underneath it.
    return if $netbar_matrix_running;

    # Wheel navigation is retained from 3.5.71 and is active only while the
    # pointer is inside the real left pane. SGR and VT200 both use 64/65 here.
    if (defined($button)
        && (int($button) == 64 || int($button) == 65)) {
        $y = netbar_mouse_calibrated_y($y);
        return if $x < 0 || $y < 0;
        return if $x >= $netbar_sidebar_mouse_width - 1
            || $y >= $netbar_sidebar_mouse_height;
        netbar_mouse_sync_layout_for_click();
        my $row = $netbar_sidebar_mouse_rows[$y];
        if ($row && defined($row->{pm_network})) {
            matrix_pm_scroll_network($row->{pm_network}, int($button) == 64 ? -1 : 1);
            return;
        }
        if ($row && ($row->{mouse_kind} // '') eq 'overflow') {
            netbar_sidebar_scroll_rows(int($button) == 64 ? -1 : 1);
            return;
        }
        int($button) == 64
            ? netbar_command_nav_up()
            : netbar_command_nav_down();
        return;
    }

    # A left drag swaps two channel windows; other moved clicks are ignored.
    return unless defined($button) && int($button) == 3;
    return unless defined($old_button)
        && (int($old_button) == 0 || int($old_button) == 2);

    my $hitbox = netbar_mouse_hitbox_at($x, $y);
    return unless $hitbox;
    if (int($x) != int($old_x) || int($y) != int($old_y)) {
        netbar_mouse_drop_channel($drag, $hitbox) if int($old_button) == 0;
        return;
    }

    # An incoming PM may reorder the list between press and release. Cancel
    # that click rather than opening/closing a different conversation.
    if ($pressed) {
        my @fields = qw(type tag name network direction chat_key connection);
        return if join("\0", map { $pressed->{$_} // '' } @fields)
            ne join("\0", map { $hitbox->{$_} // '' } @fields);
    }

    if (($hitbox->{type} // '') eq 'CHAT_NICK') {
        return unless int($old_button) == 0 && $pressed;
        my $now = time();
        if ($chat_click && $chat_click->{key} eq $hitbox->{chat_key}
                && $now >= $chat_click->{time} && $now - $chat_click->{time} <= 0.5) {
            netbar_mouse_chat_open_query($hitbox);
        }
        else {
            $netbar_mouse_chat_click = { key => $hitbox->{chat_key}, time => $now };
        }
        return;
    }
    if (int($old_button) == 0) {
        netbar_mouse_left_click($hitbox);
    }
    else {
        netbar_mouse_right_click($hitbox);
    }
}

sub netbar_mouse_key_value {
    my ($key) = @_;

    return int($key) if defined($key) && $key =~ /^-?\d+$/;
    return ord(substr($key, 0, 1)) if defined($key) && length($key);
    return undef;
}

sub netbar_mouse_finish_capture {
    $netbar_mouse_status = -1;
    $netbar_mouse_sgr_status = -1;
    $netbar_mouse_sgr_buffer = '';
    if ($netbar_mouse_timeout_tag) {
        Irssi::timeout_remove($netbar_mouse_timeout_tag);
        $netbar_mouse_timeout_tag = undef;
    }
}

sub netbar_mouse_handle_sgr_key {
    my ($value) = @_;
    return 0 unless defined $value;

    my $char = chr($value & 0xff);
    if ($char =~ /[0-9;]/) {
        # A valid sequence is short. Bound the buffer so malformed terminal data
        # cannot keep the GUI key hook in capture mode indefinitely.
        if (length($netbar_mouse_sgr_buffer) >= 63) {
            netbar_mouse_finish_capture();
            undef $netbar_mouse_drag;
            undef $netbar_mouse_chat_click;
            @netbar_mouse_sgr_press = ();
            $netbar_mouse_sgr_press_time = 0;
            return 0;
        }
        $netbar_mouse_sgr_buffer .= $char;
        netbar_mouse_arm_capture_timeout();
        return 1;
    }

    if ($char ne 'M' && $char ne 'm') {
        netbar_mouse_finish_capture();
        undef $netbar_mouse_drag;
        undef $netbar_mouse_chat_click;
        @netbar_mouse_sgr_press = ();
        $netbar_mouse_sgr_press_time = 0;
        return 0;
    }

    my $payload = $netbar_mouse_sgr_buffer;
    netbar_mouse_finish_capture();

    unless ($payload =~ /\A(\d+);(\d+);(\d+)\z/) {
        undef $netbar_mouse_drag;
        undef $netbar_mouse_chat_click;
        @netbar_mouse_sgr_press = ();
        return 0;
    }
    my ($encoded_button, $x, $y) = (int($1), int($2) - 1, int($3) - 1);
    if ($x < 0 || $y < 0) {
        undef $netbar_mouse_chat_click;
        return 0;
    }

    my $button = $encoded_button & 3;
    my $has_modifiers = $encoded_button & 28; # Shift/Alt/Ctrl
    my $is_motion = $encoded_button & 32;
    my $is_wheel  = $encoded_button & 64;

    if ($char eq 'M') {
        if (!$has_modifiers && !$is_motion && $is_wheel
            && ($button == 0 || $button == 1)) {
            netbar_mouse_event(64 + $button, $x, $y, -1, $x, $y);
            @netbar_mouse_sgr_press = ();
            $netbar_mouse_sgr_press_time = 0;
            return 1;
        }
        if (!$has_modifiers && !$is_motion && !$is_wheel
            && ($button == 0 || $button == 2)) {
            @netbar_mouse_sgr_press = ($button, $x, $y);
            $netbar_mouse_sgr_press_time = time();
            netbar_mouse_begin_drag($button, $x, $y);
        }
        elsif (!$has_modifiers && $is_motion && !$is_wheel && $button == 0) {
            # Motion reports from terminals with button tracking do not replace
            # the original press or its channel identity.
            return 1;
        }
        else {
            undef $netbar_mouse_drag;
            undef $netbar_mouse_chat_click;
            @netbar_mouse_sgr_press = ();
            $netbar_mouse_sgr_press_time = 0;
        }
        return 1;
    }

    # SGR reports release with a lower-case m and keeps the original button
    # number. Require an unmodified, recent press/release pair,
    # matching the VT200 path and preventing a stale press from firing later.
    if (@netbar_mouse_sgr_press == 3) {
        my ($old_button, $old_x, $old_y) = @netbar_mouse_sgr_press;
        my $press_age = $netbar_mouse_sgr_press_time > 0
            ? time() - $netbar_mouse_sgr_press_time
            : 999;
        @netbar_mouse_sgr_press = ();
        $netbar_mouse_sgr_press_time = 0;
        if (!$has_modifiers
            && ($button == 0 || $button == 2)
            && $button == $old_button
            && !$is_motion && !$is_wheel
            && $press_age >= 0 && $press_age <= 15.0) {
            netbar_mouse_event(3, $x, $y, $old_button, $old_x, $old_y);
        }
        else {
            undef $netbar_mouse_chat_click;
        }
    }
    else {
        undef $netbar_mouse_chat_click;
    }
    undef $netbar_mouse_drag;
    return 1;
}

sub netbar_mouse_key_hook {
    my ($key) = @_;
    return if $netbar_mouse_status == -1 && $netbar_mouse_sgr_status == -1;

    my $value = netbar_mouse_key_value($key);
    if (!defined $value) {
        netbar_mouse_finish_capture();
        undef $netbar_mouse_drag;
        undef $netbar_mouse_chat_click;
        @netbar_mouse_sgr_press = ();
        $netbar_mouse_sgr_press_time = 0;
        Irssi::signal_stop();
        return;
    }

    if ($netbar_mouse_sgr_status != -1) {
        netbar_mouse_handle_sgr_key($value);
        Irssi::signal_stop();
        return;
    }

    if ($netbar_mouse_status == 0) {
        @netbar_mouse_previous = @netbar_mouse_combo;
    }

    $netbar_mouse_combo[$netbar_mouse_status] = $value - 32;
    $netbar_mouse_status++;

    if ($netbar_mouse_status == 3) {
        netbar_mouse_finish_capture();

        # Convert XTerm's one-based screen coordinates to zero-based values.
        $netbar_mouse_combo[1]--;
        $netbar_mouse_combo[2]--;

        if ($netbar_mouse_combo[0] == 0 || $netbar_mouse_combo[0] == 2) {
            netbar_mouse_begin_drag(@netbar_mouse_combo[0 .. 2]);
        }
        else {
            # Includes the existing VT200 wheel navigation (64/65).
            netbar_mouse_event(
                @netbar_mouse_combo[0 .. 2],
                @netbar_mouse_previous[0 .. 2],
            );
        }
    }

    Irssi::signal_stop();
}

sub netbar_mouse_resize_settle {
    undef $netbar_mouse_resize_tag;
    netbar_mouse_update_terminal_size();

    # Width determines the sidebar rows and every x hitbox. A resize must
    # therefore rebuild both visible rows and their click ranges.
    $force_layout = 1;
    netbar_schedule_refresh();
    netbar_mouse_tracking_rearm();
}

sub netbar_mouse_terminal_resized {
    undef $netbar_mouse_chat_click;
    undef $netbar_mouse_press_target;
    $netbar_matrix_cursor_last_state = '';
    undef $netbar_mouse_drag;
    netbar_mouse_update_terminal_size();
    netbar_matrix_cancel(1) if $netbar_matrix_running;
    $force_layout = 1;
    netbar_schedule_refresh();

    Irssi::timeout_remove($netbar_mouse_resize_tag)
        if defined $netbar_mouse_resize_tag;
    $netbar_mouse_resize_tag = Irssi::timeout_add_once(
        180,
        'netbar_mouse_resize_settle',
        0,
    );
}

sub netbar_mouse_install {
    return if $netbar_mouse_installed;

    $netbar_mouse_binding_conflict = matrix_saved_external_mouse_binding_present() ? 1 : 0;

    Irssi::command_bind('matrixmousexterm', \&netbar_mouse_command_xterm);
    Irssi::command_bind('matrixmousesgr',   \&netbar_mouse_command_sgr);

    # Never steal a pre-existing raw mouse binding from another script. MATRIX's
    # sidebar still tracks terminal resizes, but mouse clicks stay with the
    # existing owner until MATRIX is reloaded after that conflict is removed.
    if (!$netbar_mouse_binding_conflict) {
        Irssi::command('^bind meta-[M command MATRIXMOUSEXTERM');
        Irssi::command('^bind meta-[< command MATRIXMOUSESGR');
        $matrix_mouse_raw_bindings_installed = 1;
        Irssi::signal_add_first('gui key pressed', \&netbar_mouse_key_hook);
    }
    Irssi::signal_add_last('terminal resized', \&netbar_mouse_terminal_resized);

    $netbar_mouse_installed = 1;
    netbar_mouse_update_terminal_size();
    netbar_mouse_apply_setting() unless $netbar_mouse_binding_conflict;
}

sub netbar_mouse_uninstall {
    undef $netbar_mouse_drag;
    netbar_mouse_tracking_disable();

    if ($netbar_mouse_timeout_tag) {
        Irssi::timeout_remove($netbar_mouse_timeout_tag);
        $netbar_mouse_timeout_tag = undef;
    }
    if ($netbar_mouse_resize_tag) {
        Irssi::timeout_remove($netbar_mouse_resize_tag);
        $netbar_mouse_resize_tag = undef;
    }

    if ($netbar_mouse_installed) {
        Irssi::signal_remove('gui key pressed', \&netbar_mouse_key_hook)
            unless $netbar_mouse_binding_conflict;
        Irssi::signal_remove('terminal resized', \&netbar_mouse_terminal_resized);
        Irssi::command_unbind('matrixmousexterm', \&netbar_mouse_command_xterm);
        Irssi::command_unbind('matrixmousesgr',   \&netbar_mouse_command_sgr);
    }

    $netbar_mouse_installed = 0;
    $netbar_mouse_status = -1;
    $netbar_mouse_sgr_status = -1;
    $netbar_mouse_sgr_buffer = '';
    @netbar_mouse_sgr_press = ();
    $netbar_mouse_sgr_press_time = 0;
    $netbar_mouse_layout_width = 0;
    $netbar_mouse_layout_height = 0;
    $netbar_mouse_binding_conflict = 0;
}

sub netbar_command_mouse {
    my ($data) = @_;
    $data //= '';
    $data =~ s/^\s+|\s+$//g;

    if ($data eq '' || lc($data) eq 'status') {
        my $state = Irssi::settings_get_bool('matrix_mouse_enabled')
            ? 'ON' : 'OFF';
        my $offset = Irssi::settings_get_int('matrix_mouse_offset');
        my $protocol = netbar_mouse_protocol();
        Irssi::print(
            'MATRIX mouse: ' . $state
            . ', mode=' . $protocol
            . ', offset=' . $offset
            . ', terminal=' . ($netbar_mouse_screen_width || '?')
            . 'x' . ($netbar_mouse_screen_height || '?')
            . ', layout=' . ($netbar_mouse_layout_width || '?')
            . 'x' . ($netbar_mouse_layout_height || '?')
            . ($netbar_mouse_binding_conflict ? ', conflict=external-mouse-binding' : '')
            . '. Left click=open, double left-click a public message or /NAMES nick=PM, left drag=swap channels within same network, right click=/wc.'
        );
        return;
    }

    if (lc($data) eq 'on') {
        if ($netbar_mouse_binding_conflict) {
            Irssi::print('MATRIX mouse: external meta-[M/meta-[< binding detected; mouse takeover is disabled until the conflict is removed and MATRIX is reloaded.', Irssi::MSGLEVEL_CLIENTNOTICE());
            return;
        }
        Irssi::settings_set_bool('matrix_mouse_enabled', 1);
        netbar_mouse_tracking_enable();
        Irssi::print('MATRIX mouse: enabled.');
        return;
    }

    if (lc($data) eq 'off') {
        Irssi::settings_set_bool('matrix_mouse_enabled', 0);
        netbar_mouse_tracking_disable();
        Irssi::print('MATRIX mouse: disabled.');
        return;
    }


    if (lc($data) eq 'precise') {
        if (Irssi::settings_get_bool('matrix_mouse_enabled')) {
            netbar_mouse_tracking_disable();
            netbar_mouse_tracking_enable();
        }
        Irssi::print('MATRIX mouse: mode=precise (SGR 1006 + VT200 fallback, press+release validation).');
        return;
    }

    if (lc($data) eq 'links') {
        # Compatibility with 4.3.1: X10 was unreliable and is intentionally
        # mapped back to the working precise mode.
        if (Irssi::settings_get_bool('matrix_mouse_enabled')) {
            netbar_mouse_tracking_disable();
            netbar_mouse_tracking_enable();
        }
        Irssi::print('MATRIX mouse: links mode was retired; restored mode=precise.');
        return;
    }

    if ($data =~ /^offset\s+(\d+)$/i) {
        my $offset = int($1);
        if ($offset > 20) {
            Irssi::print('MATRIX mouse: offset must be between 0 and 20.');
            return;
        }
        Irssi::settings_set_int('matrix_mouse_offset', $offset);
        Irssi::print('MATRIX mouse: offset set to ' . $offset . ' (2 = no row correction).');
        return;
    }

    Irssi::print('Usage: /matrixmouse [on|off|status|precise|offset N]');
}

sub netbar_refresh_after_native_ctrl_x {
    netbar_remember_active_item();
    netbar_clear_current_channel_activity();
    netbar_clear_status_attention() if netbar_status_window_is_active();
    netbar_schedule_refresh();
    clients_redraw_statusbar() if defined &clients_redraw_statusbar;
}

sub netbar_command_next_network {
    my ($data, $server, $witem) = @_;

    my $window = Irssi::active_win();
    if (!$window) {
        Irssi::command('^window item next');
        return;
    }

    # The bindable Irssi action next_window_item has special handling for an
    # empty Status window, but the textual /WINDOW ITEM NEXT command does not.
    # Cycle the selected network explicitly so Ctrl-X works in Status 1 too.
    if (defined($window->{refnum}) && int($window->{refnum}) == 1) {
        my @networks = netbar_connected_networks();
        if (@networks >= 2) {
            my $active_server = $window->{active_server}
                || eval { Irssi::active_server() };
            my $current_network = netbar_network_name_for_server($active_server);
            my $current_index = -1;

            for my $index (0 .. $#networks) {
                if (length($current_network)
                    && lc($networks[$index]) eq lc($current_network)) {
                    $current_index = $index;
                    last;
                }
            }

            my $target_index = $current_index >= 0
                ? ($current_index + 1) % scalar(@networks)
                : 0;
            my $target_network = $networks[$target_index];

            if (netbar_focus_network_status($target_network)) {
                netbar_refresh_after_native_ctrl_x();
                return;
            }
        }

        # With only one connected network there is nothing to cycle. Keep a
        # harmless native fallback instead of leaving the key unhandled.
        Irssi::command('^window item next');
        netbar_refresh_after_native_ctrl_x();
        return;
    }

    my $active_item = $window->{active};
    my $active_kind = netbar_item_kind($active_item);
    if (!length($active_kind) || !$active_item->{server}) {
        Irssi::command('^window item next');
        netbar_refresh_after_native_ctrl_x();
        return;
    }

    netbar_remember_active_item();

    my @networks = netbar_connected_networks();
    if (@networks < 2) {
        Irssi::command('^window item next');
        netbar_refresh_after_native_ctrl_x();
        return;
    }

    my $current_network = netbar_network_name_for_server($active_item->{server});
    my $current_index = -1;
    for my $index (0 .. $#networks) {
        if (lc($networks[$index]) eq lc($current_network)) {
            $current_index = $index;
            last;
        }
    }

    # Try every other connected network in configured order. Prefer its last
    # viewed channel/QUERY, then its remembered/first channel, then first QUERY.
    for my $step (1 .. $#networks) {
        my $index = ($current_index + $step) % scalar(@networks);
        my $target_network = $networks[$index];
        next if lc($target_network) eq lc($current_network);

        my $target = netbar_resolve_remembered_item($target_network)
            || netbar_fallback_item_for_network($target_network);
        return if $target && netbar_focus_item($target);

        # A connected network without channels/queries is still reachable via
        # Status window 1, with that server selected.
        return if netbar_focus_network_status($target_network);
    }

    # Defensive fallback: never leave Ctrl-X dead if all target lookups failed.
    Irssi::command('^window item next');
    netbar_refresh_after_native_ctrl_x();
}

# Build the same one-column channel order rendered in the sidebar. Navigation
# and drawing therefore always use identical geometry.
sub netbar_navigation_grid {
    my @networks = netbar_connected_networks();
    return [] unless @networks;

    my @network_data;
    my $refnum_width = 2;

    for my $network (@networks) {
        my @channels = netbar_channel_entries_for_network($network);
        my @queries = defined(&pmu_navigation_entries_for_network)
            ? pmu_navigation_entries_for_network($network)
            : ();
        for my $entry (@channels) {
            my $digits = length('' . int($entry->{refnum}));
            $refnum_width = $digits if $digits > $refnum_width;
        }

        push @network_data, {
            network  => $network,
            channels => \@channels,
            queries  => \@queries,
        };
    }

    for my $data (@network_data) {
        $data->{display} = [ map {
            netbar_channel_display_data($_, undef, $refnum_width)
        } @{ $data->{channels} } ];
    }

    my $columns = 1;
    $_->{columns} = $columns for @network_data;

    return \@network_data;
}

sub netbar_navigation_network_index {
    my ($network_data_ref, $network) = @_;
    return -1 unless defined($network) && length($network);

    for my $index (0 .. $#$network_data_ref) {
        my $candidate = $network_data_ref->[$index]->{network} // '';
        return $index if length($candidate)
            && lc($candidate) eq lc($network);
    }
    return -1;
}

sub netbar_navigation_channel_index {
    my ($entries_ref, $item) = @_;
    return undef unless $item && $item->{server}
        && defined($item->{name}) && length($item->{name});

    my $tag = netbar_server_tag($item->{server});
    for my $index (0 .. $#$entries_ref) {
        my $entry = $entries_ref->[$index];
        next unless $entry && $entry->{server};
        next unless netbar_server_tag($entry->{server}) eq $tag;
        return $index if matrix_irc_casefold($item->{server}, $entry->{name}) eq matrix_irc_casefold($item->{server}, $item->{name});
    }
    return undef;
}

sub netbar_navigation_query_index {
    my ($entries_ref, $item) = @_;
    return undef unless $item && $item->{server}
        && defined($item->{name}) && length($item->{name});

    my $tag = netbar_server_tag($item->{server});
    for my $index (0 .. $#$entries_ref) {
        my $entry = $entries_ref->[$index];
        next unless $entry;
        next unless lc($entry->{tag} // '') eq lc($tag);
        return $index if matrix_irc_casefold($item->{server}, $entry->{nick}) eq matrix_irc_casefold($item->{server}, $item->{name});
    }
    return undef;
}

sub netbar_navigation_context {
    my ($network_data_ref) = @_;
    my $window = Irssi::active_win();
    return undef unless $window;

    my $item = $window->{active};
    my $kind = netbar_item_kind($item);
    my $server = $item && $item->{server} ? $item->{server} : undef;
    $server = $window->{active_server}
        if !$server && $window->{active_server};
    $server = eval { Irssi::active_server() }
        if !$server;

    my $network = netbar_network_name_for_server($server);
    my $network_index = netbar_navigation_network_index(
        $network_data_ref,
        $network,
    );
    return undef if $network_index < 0;

    my $channel_index;
    if ($kind eq 'CHANNEL') {
        $channel_index = netbar_navigation_channel_index(
            $network_data_ref->[$network_index]->{channels},
            $item,
        );
    }

    my $query_index;
    if ($kind eq 'QUERY') {
        $query_index = netbar_navigation_query_index(
            $network_data_ref->[$network_index]->{queries} || [],
            $item,
        );
    }

    return {
        window         => $window,
        item           => $item,
        kind           => $kind,
        network        => $network,
        network_index  => $network_index,
        channel_index  => $channel_index,
        query_index    => $query_index,
        status         => (
            defined($window->{refnum}) && int($window->{refnum}) == 1
        ) ? 1 : 0,
    };
}

sub netbar_navigation_focus_entry {
    my ($entry) = @_;
    return 0 unless $entry && $entry->{channel};
    return netbar_focus_item($entry->{channel});
}

sub netbar_navigation_focus_query_entry {
    my ($entry) = @_;
    return 0 unless $entry && $entry->{query};
    my $query = $entry->{query};
    matrix_sidebar_pm_reveal_query_entry($entry)
        if defined &matrix_sidebar_pm_reveal_query_entry;
    return netbar_focus_item($query);
}

# Alt-Left / Alt-Right are dedicated exclusively to every live PM conversation
# across all connected networks. They follow stable network/refnum order and
# wrap at both ends; they do not depend on the native Act item's current width.
sub netbar_navigate_horizontal {
    my ($direction) = @_;
    $direction = $direction < 0 ? -1 : 1;

    my $activity = matrix_sidebar_activity_data();
    my @queries = grep {
        $_ && ($_->{type} // '') eq 'QUERY'
            && $_->{source_entry} && $_->{source_entry}->{query}
    } @{ $activity->{all} || [] };
    my $count = scalar @queries;
    return unless $count;

    my $window = Irssi::active_win();
    my $active_item = $window ? $window->{active} : undef;
    my $active_kind = netbar_item_kind($active_item);
    my $active_tag = $active_item && $active_item->{server}
        ? lc(netbar_server_tag($active_item->{server}))
        : '';
    my $active_name = $active_item && defined($active_item->{name})
        ? matrix_irc_casefold($active_item->{server}, $active_item->{name})
        : '';

    my $current_index;
    if ($active_kind eq 'QUERY' && length($active_tag) && length($active_name)) {
        for my $index (0 .. $#queries) {
            my $candidate = $queries[$index];
            if (lc($candidate->{tag} // '') eq $active_tag
                && matrix_tag_casefold($candidate->{tag}, $candidate->{name}) eq $active_name) {
                $current_index = $index;
                last;
            }
        }
    }

    my $target;
    if (defined $current_index) {
        $target = ($current_index + $direction + $count) % $count;
    }
    else {
        $target = $direction < 0 ? $count - 1 : 0;
    }

    netbar_navigation_focus_query_entry($queries[$target]->{source_entry});
}

# Alt-Up / Alt-Down follow the visual channel columns. Inside one network they
# move between wrapped channel rows. At the top/bottom edge they enter the
# preceding/following network and keep the same column whenever possible.
# In Status window 1 they move only between network headers.
sub netbar_navigate_vertical {
    my ($direction) = @_;
    $direction = $direction < 0 ? -1 : 1;

    my $network_data_ref = netbar_navigation_grid();
    my $network_count = scalar @$network_data_ref;
    return unless $network_count;

    my $context = netbar_navigation_context($network_data_ref);
    return unless $context;

    my $network_index = $context->{network_index};
    my $current_data = $network_data_ref->[$network_index];
    my $columns = int($current_data->{columns} || 1);
    $columns = 1 if $columns < 1;

    if ($context->{status}) {
        return if $network_count < 2;
        my $target_network_index = (
            $network_index + $direction + $network_count
        ) % $network_count;
        netbar_focus_network_status(
            $network_data_ref->[$target_network_index]->{network},
        );
        netbar_refresh_after_native_ctrl_x();
        return;
    }

    if ($context->{kind} eq 'QUERY') {
        # Horizontal arrows own PM navigation. From QUERY, vertical arrows
        # return to the remembered channel of this network (or its nearest
        # visual edge) instead of duplicating the PM cycle.
        my $entries = $current_data->{channels} || [];
        my $count = scalar @$entries;
        return unless $count;

        my $selected = netbar_selected_entry_for_network(
            $current_data->{network},
            $entries,
        );
        my $selected_index = netbar_navigation_channel_index(
            $entries,
            $selected ? $selected->{channel} : undef,
        );
        my $target = defined($selected_index)
            ? $selected_index
            : ($direction < 0 ? $count - 1 : 0);
        netbar_navigation_focus_entry($entries->[$target]);
        return;
    }

    my $entries = $current_data->{channels};
    my $count = scalar @$entries;
    my $current_index = $context->{channel_index};

    my $column = 0;
    my $row;
    if (defined($current_index)) {
        ($row, $column) = netbar_grid_position_for_index($current_index, $columns);
    }
    elsif ($count) {
        my $selected = netbar_selected_entry_for_network(
            $current_data->{network},
            $entries,
        );
        my $selected_index = netbar_navigation_channel_index(
            $entries,
            $selected ? $selected->{channel} : undef,
        );
        if (defined $selected_index) {
            (undef, $column) = netbar_grid_position_for_index($selected_index, $columns);
        }
    }

    # Follow the actual visual row above/below. In COMPACT this means 1->2->3
    # vertically inside a column, then 10->11->12 in the next three-row block.
    if ($context->{kind} eq 'CHANNEL' && defined($current_index) && defined($row)) {
        my $inside_target = netbar_grid_index_for_position(
            $row + $direction,
            $column,
            $columns,
            $count,
        );
        if (defined $inside_target) {
            netbar_navigation_focus_entry($entries->[$inside_target]);
            return;
        }
    }

    # With one network, wrap to the opposite visual edge of the same column.
    if ($network_count == 1) {
        return unless $count > 1;
        my $target_index = netbar_grid_edge_index_for_column(
            $count,
            $columns,
            $column,
            $direction,
        );
        return if !defined($target_index)
            || (defined($current_index) && $target_index == $current_index);
        netbar_navigation_focus_entry($entries->[$target_index]);
        return;
    }

    my $target_network_index = (
        $network_index + $direction + $network_count
    ) % $network_count;
    my $target_data = $network_data_ref->[$target_network_index];
    my $target_entries = $target_data->{channels};
    my $target_count = scalar @$target_entries;
    my $target_columns = int($target_data->{columns} || 1);
    $target_columns = 1 if $target_columns < 1;

    my $target_column = int($column * $target_columns / $columns);
    $target_column = $target_columns - 1 if $target_column >= $target_columns;
    $target_column = 0 if $target_column < 0;

    if (!$target_count) {
        netbar_focus_network_status($target_data->{network});
        netbar_refresh_after_native_ctrl_x();
        return;
    }

    my $target_index = netbar_grid_edge_index_for_column(
        $target_count,
        $target_columns,
        $target_column,
        $direction,
    );
    netbar_navigation_focus_entry($target_entries->[$target_index])
        if defined $target_index;
}

sub netbar_command_nav_left  { netbar_navigate_horizontal(-1); }
sub netbar_command_nav_right { netbar_navigate_horizontal(1); }
sub netbar_command_nav_up    { netbar_navigate_vertical(-1); }
sub netbar_command_nav_down  { netbar_navigate_vertical(1); }

sub netbar_install_navigation_bindings {
    # Native symbolic names used by Irssi/terminfo.
    Irssi::command('^bind mleft  command MATRIXNAVLEFT');
    Irssi::command('^bind mright command MATRIXNAVRIGHT');
    Irssi::command('^bind mup    command MATRIXNAVUP');
    Irssi::command('^bind mdown  command MATRIXNAVDOWN');

    # Literal xterm modified-arrow sequences seen through GNU screen, SSH and
    # terminals which do not translate Alt+arrows into mleft/mright/mup/mdown.
    # Supporting both routes prevents Irssi's default split-window action from
    # taking over and leaving navigation apparently trapped in one network.
    Irssi::command('^bind meta-[1;3D command MATRIXNAVLEFT');
    Irssi::command('^bind meta-[1;3C command MATRIXNAVRIGHT');
    Irssi::command('^bind meta-[1;3A command MATRIXNAVUP');
    Irssi::command('^bind meta-[1;3B command MATRIXNAVDOWN');
}

sub netbar_signal_window_focus_changed {
    undef $netbar_mouse_chat_click;
    my ($signal_window, $previous_window) = @_;
    return if $netbar_sidebar_layout_guard;

    my $active = Irssi::active_win();
    if ($active && netbar_sidebar_is_window($active)) {
        netbar_sidebar_schedule_focus_restore();
        return;
    }

    # Status window 1 is shared. Make its server follow the network of the
    # content window which the user has just left.
    if ($active && int($active->{refnum} // 0) == 1
        && $previous_window && int($previous_window->{refnum} // 0) != 1
        && !netbar_sidebar_is_window($previous_window)) {
        my $previous_item = $previous_window->{active};
        if ($previous_item && $previous_item->{server}) {
            my $wanted_tag = netbar_server_tag($previous_item->{server});
            my $selected = $active->{active_server} || eval { Irssi::active_server() };
            my $selected_tag = netbar_server_tag($selected);
            Irssi::command('^window server ' . $wanted_tag)
                if length($wanted_tag) && $wanted_tag ne $selected_tag;
        }
    }

    if ($active && defined($active->{refnum})) {
        $netbar_sidebar_last_content_refnum = int($active->{refnum});
    }

    netbar_remember_active_item();
    netbar_clear_current_channel_activity();
    netbar_clear_status_attention() if netbar_status_window_is_active();
    netbar_schedule_refresh();
}

sub netbar_signal_query_nick_changed {
    my ($server, $new_nick, $old_nick, $address) = @_;
    return unless $server && defined($new_nick) && defined($old_nick);
    my $tag = netbar_server_tag($server);
    my $changed = 0;

    for my $entry (values %last_item_by_network) {
        next unless $entry && ($entry->{kind} || '') eq 'QUERY';
        next unless lc($entry->{tag} || '') eq lc($tag);
        next unless matrix_irc_casefold($server, $entry->{name}) eq matrix_irc_casefold($server, $old_nick);
        $entry->{name} = $new_nick;
        $changed = 1;
    }
    netbar_schedule_refresh() if $changed;
}

sub netbar_signal_query_destroyed {
    my ($query) = @_;
    return unless $query && $query->{server} && defined($query->{name});
    my $tag = netbar_server_tag($query->{server});
    my $changed = 0;

    for my $key (keys %last_item_by_network) {
        my $entry = $last_item_by_network{$key};
        next unless $entry && ($entry->{kind} || '') eq 'QUERY';
        next unless lc($entry->{tag} || '') eq lc($tag);
        next unless matrix_irc_casefold($query->{server}, $entry->{name}) eq matrix_irc_casefold($query->{server}, $query->{name});
        delete $last_item_by_network{$key};
        $changed = 1;
    }
    netbar_schedule_refresh() if $changed;
}

sub netbar_signal_channel_ready {
    my ($channel) = @_;
    netbar_clear_channel_activity($channel);
    netbar_schedule_refresh();

    if ($channel) {
        netbar_sidebar_disable_autostick();
        my $window = eval { $channel->window() };
        netbar_sidebar_unstick_window($window) if $window;
        netbar_sidebar_schedule_normalise();
    }

    if ($channel && $channel->{server}) {
        netbar_oper_schedule_for_server($channel->{server}, 3000);
    }
}

sub netbar_signal_channel_gone {
    undef $netbar_mouse_drag;
    my ($channel) = @_;
    netbar_clear_channel_activity($channel);
    if ($channel && $channel->{server}) {
        $netbar_sidebar_recovery_network =
            netbar_network_name_for_server($channel->{server});
    }
    netbar_schedule_refresh();
}

sub netbar_connected_networks {
    my %seen;

    for my $server (Irssi::servers()) {
        next unless $server && $server->{connected};
        my $network = netbar_network_name_for_server($server);
        $seen{$network} = 1 if length $network;
    }

    for my $channel (Irssi::channels()) {
        next unless $channel && $channel->{joined};
        my $network = netbar_network_name_for_server($channel->{server});
        $seen{$network} = 1 if length $network;
    }

    return netbar_preferred_networks(keys %seen);
}

sub netbar_channel_entries_for_network {
    my ($wanted_network) = @_;
    my @entries;
    my $active_window = Irssi::active_win();

    for my $channel (Irssi::channels()) {
        next unless $channel && $channel->{joined};

        my $server = $channel->{server};
        next unless $server;

        my $network = netbar_network_name_for_server($server);
        next unless lc($network) eq lc($wanted_network);

        my $window = $channel->window();
        next unless $window;

        my $active = 0;
        if ($active_window && defined $active_window->{refnum}
            && defined $window->{refnum}
            && $active_window->{refnum} == $window->{refnum}) {
            $active = $channel->is_active() ? 1 : 0;
        }

        push @entries, {
            refnum     => int($window->{refnum}),
            name       => $channel->{name} // '',
            active     => $active,
            channel    => $channel,
            server     => $server,
        };
    }

    @entries = sort {
        $a->{refnum} <=> $b->{refnum}
            || lc($a->{name}) cmp lc($b->{name})
    } @entries;

    return @entries;
}

sub netbar_selected_entry_for_network {
    my ($network, $entries_ref) = @_;
    my @entries = @{$entries_ref};
    return undef unless @entries;

    my ($active) = grep { $_->{active} } @entries;
    if ($active) {
        $last_channel_by_network{lc $network} = $active->{name};
        return $active;
    }

    my $last = $last_channel_by_network{lc $network} // '';
    if (length $last) {
        my ($remembered) = grep { matrix_irc_casefold($_->{server}, $_->{name}) eq matrix_irc_casefold($_->{server}, $last) } @entries;
        return $remembered if $remembered;
    }

    $last_channel_by_network{lc $network} = $entries[0]->{name};
    return $entries[0];
}

sub netbar_mode_plain {
    my ($mode) = @_;
    $mode //= '';
    $mode =~ s/^\s+|\s+$//g;
    return '' unless length $mode;
    $mode = '+' . $mode unless $mode =~ /^[+-]/;
    return netbar_plain_dynamic_text($mode);
}

sub netbar_mode_formatted {
    my ($mode) = @_;
    $mode = netbar_mode_plain($mode);
    return '' unless length $mode;
    return '%K(%w' . netbar_format_dynamic_text($mode) . '%K)';
}


sub netbar_active_width {
    # Sidebar measuring/rendering supplies the exact width of the left pane.
    # Never replace it with the full PTY width or channel rows would wrap and
    # mouse hitboxes would no longer match the visible list.
    if (defined($netbar_width_override) && $netbar_width_override > 0) {
        return int($netbar_width_override);
    }

    # Irssi's cached window width can lag one resize event behind on some SSH
    # terminals. Refresh the real PTY geometry first and prefer it whenever it
    # is available; this prevents a row from wrapping into 2,2,1,1 while the
    # sidebar still believes the old, wider size is active.
    eval { netbar_mouse_update_terminal_size() }
        if defined &netbar_mouse_update_terminal_size;

    my $window = Irssi::active_win();
    my $width = 0;

    if ($netbar_mouse_screen_width >= 20) {
        $width = int($netbar_mouse_screen_width);
    }
    elsif ($window && defined($window->{width}) && $window->{width} =~ /^\d+$/) {
        $width = int($window->{width});
    }
    elsif (defined($ENV{COLUMNS}) && $ENV{COLUMNS} =~ /^\d+$/) {
        $width = int($ENV{COLUMNS});
    }

    # Safe fallback for builds which expose neither PTY nor GUI dimensions.
    $width = 160 if $width < 20;
    return $width;
}

sub netbar_oper_channel_has_other_oper {
    my ($channel, $server) = @_;
    return 0 unless Irssi::settings_get_bool('network_statusbars_oper_marker');
    return 0 unless $channel && $server;

    my $tag = netbar_server_tag($server);
    return 0 unless length $tag && ref($oper_nicks_by_tag{$tag}) eq 'HASH';

    my $generation = $oper_membership_generation{$tag} || 1;
    my $cache_key = netbar_channel_key($server, $channel->{name} // '');
    if (length($cache_key)
        && ref($oper_channel_cache{$cache_key}) eq 'HASH'
        && int($oper_channel_cache{$cache_key}->{generation} // 0)
            == $generation) {
        return $oper_channel_cache{$cache_key}->{has_oper} ? 1 : 0;
    }

    my $own = matrix_irc_casefold($server, $server->{nick});
    my $has_oper = 0;
    my @nicks = eval { $channel->nicks() };
    if (!$@) {
        for my $record (@nicks) {
            next unless $record;
            my $nick = $record->{nick} // $record->{name} // '';
            next unless length $nick;
            next if length($own) && matrix_irc_casefold($server, $nick) eq $own;
            if ($oper_nicks_by_tag{$tag}->{matrix_irc_casefold($server, $nick)}) {
                $has_oper = 1;
                last;
            }
        }
    }

    if (length $cache_key) {
        $oper_channel_cache{$cache_key} = {
            generation => $generation,
            has_oper   => $has_oper,
        };
    }

    return $has_oper;
}

sub netbar_truncate_name {
    my ($name, $max_length) = @_;
    $name = netbar_plain_dynamic_text($name);
    return '' if $max_length <= 0;
    return $name if matrix_text_width($name) <= $max_length;
    return '~' if $max_length == 1;
    return matrix_text_truncate_cells($name, $max_length - 1) . '~';
}

sub netbar_channel_display_data {
    my ($entry, $max_item_width, $refnum_width) = @_;
    # Keep at least two number cells. Together with the dedicated marker cell
    # and one separator this aligns every slash/channel name, not the digits:
    #   >  2/#Siedlce
    #     19/#kawa
    #     11/#atw
    $refnum_width = 2 unless defined($refnum_width) && $refnum_width >= 2;

    my $active = $entry->{active} ? 1 : 0;
    my $activity_key = netbar_channel_key($entry->{server}, $entry->{name});
    my $is_muted = netbar_channel_is_muted($entry->{server}, $entry->{name});
    my $has_message_activity = length($activity_key) && $message_activity{$activity_key}
        ? 1 : 0;
    my $has_nick_attention = length($activity_key) && $nick_attention{$activity_key}
        ? 1 : 0;

    my $reading_live = !$matrix_mention_jump && $active && !matrix_window_scrolled(eval { $entry->{channel}->window() });
    if (($reading_live || $is_muted) && length $activity_key) {
        delete $message_activity{$activity_key};
        delete $nick_attention{$activity_key};
        delete $channel_unread_count{$activity_key};
        matrix_mentions_clear($activity_key);
        $has_message_activity = 0;
        $has_nick_attention = 0;
    }

    # The marker column is now reserved only for the active-channel arrow.
    # Ordinary channel activity is indicated by brightening the complete entry;
    # an own-nick mention additionally lights the window number steadily.
    my $marker_char = $active ? '>' : ' ';
    my $marker_colour = $active ? '%B' : '%K';

    # Inactive channels with new text stay normal gray. An own-nick mention
    # keeps the slash and channel name steady and lights only the window number.
    # Bright white remains reserved for the current channel.
    my $state_colour = $is_muted
        ? '%K'
        : ($active
            ? '%W'
            : (($has_message_activity || $has_nick_attention) ? '%w' : '%K'));
    my $refnum_colour = (!$active && !$is_muted && $has_nick_attention)
        ? '%M'
        : $state_colour;
    my $refnum_raw = '' . int($entry->{refnum});
    my $refnum_padding = ' ' x ($refnum_width - length($refnum_raw));
    my $marker_plain = $marker_char . ' ';
    my $marker_formatted = $marker_colour . $marker_char . '%K ';
    my $refnum_plain = $refnum_padding . $refnum_raw;
    # Padding stays neutral; only the actual number changes colour.
    my $refnum_formatted = '%K' . $refnum_padding
        . $refnum_colour . $refnum_raw;

    my $has_oper = netbar_oper_channel_has_other_oper(
        $entry->{channel},
        $entry->{server},
    );
    my $oper_plain = $has_oper ? ' *' : '';
    my $oper_formatted = $has_oper ? ' %K*' . $state_colour : '';

    my $mode_plain = netbar_mode_plain($entry->{channel}->{mode} // '');
    my $shown_mode_plain = $active ? $mode_plain : '';
    my $shown_mode_formatted = $active
        ? ($is_muted
            ? '%K(' . netbar_format_dynamic_text($mode_plain) . '%K)'
            : netbar_mode_formatted($mode_plain))
        : '';

    my $unread = length($activity_key) ? ($channel_unread_count{$activity_key} // 0) : 0;
    my $attention_plain = $has_nick_attention && !$is_muted ? ' !' : '';
    my $unread_plain = $unread > 0 ? ' (' . $unread . ')' : '';
    my $unread_formatted = length($unread_plain) ? $state_colour . $unread_plain : '';

    my $name_plain = netbar_plain_dynamic_text($entry->{name});

    if (defined($max_item_width) && $max_item_width > 0) {
        # Padded marker + separating space + refnum + slash + " *" reserve.
        my $fixed_length = matrix_text_width($marker_plain)
            + matrix_text_width($refnum_plain) + 1 + 2
            + (length($shown_mode_plain) ? matrix_text_width($shown_mode_plain) + 2 : 0)
            + ($is_muted ? 4 : 0)
            + matrix_text_width($unread_plain . $attention_plain);
        my $name_room = $max_item_width - $fixed_length;
        $name_plain = netbar_truncate_name($name_plain, $name_room);
    }

    my $name_formatted = netbar_format_dynamic_text($name_plain);

    my $mute_plain = $is_muted ? ' [M]' : '';
    my $mute_formatted = $is_muted ? '%K [%BM%K]' : '';

    my $plain = $marker_plain . $refnum_plain . '/' . $name_plain . $unread_plain . $attention_plain . $oper_plain;
    $plain .= '(' . $shown_mode_plain . ')' if length $shown_mode_plain;
    $plain .= $mute_plain;

    # Reserve every channel's mode width and two trailing operator-marker cells.
    # A yellow " *" can therefore appear after the channel name without moving
    # the following columns or exposing the theme background.
    my $reserved_plain = $marker_plain . $refnum_plain . '/'
        . $name_plain . $unread_plain . $attention_plain . ' *';
    $reserved_plain .= '(' . $mode_plain . ')' if length $mode_plain;
    $reserved_plain .= $mute_plain;

    my $formatted = $marker_formatted
        . $refnum_formatted
        . '%K/' . $state_colour
        . $name_formatted
        . $unread_formatted
        . (length($attention_plain) ? '%M' . $attention_plain . $state_colour : '')
        . $oper_formatted
        . $shown_mode_formatted
        . $mute_formatted;

    return {
        plain          => $plain,
        reserved_plain => $reserved_plain,
        formatted      => $formatted,
    };
}

# Return natural widths shared by corresponding columns of every network. The
# sidebar intentionally uses one column, but the helpers remain generic so
# keyboard navigation and rendering share exactly the same row-major geometry.
sub netbar_shared_column_widths {
    my ($network_data_ref, $columns) = @_;
    my @widths = (0) x $columns;

    for my $data (@$network_data_ref) {
        my $display_ref = $data->{display} || [];
        for my $index (0 .. $#$display_ref) {
            my $column = netbar_grid_column_for_index($index, $columns);
            my $width = matrix_text_width($display_ref->[$index]->{reserved_plain});
            $widths[$column] = $width if $width > $widths[$column];
        }
    }

    return @widths;
}

sub netbar_grid_column_for_index {
    my ($index, $columns) = @_;
    $index = int($index // 0);
    $columns = int($columns // 1);
    $columns = 1 if $columns < 1;
    return $index % $columns;
}

sub netbar_grid_position_for_index {
    my ($index, $columns) = @_;
    $index = int($index // 0);
    $columns = int($columns // 1);
    $columns = 1 if $columns < 1;
    return (int($index / $columns), $index % $columns);
}

sub netbar_grid_index_for_position {
    my ($row, $column, $columns, $count) = @_;
    $row = int($row // 0);
    $column = int($column // 0);
    $columns = int($columns // 1);
    $count = int($count // 0);
    return undef if $row < 0 || $column < 0
        || $column >= $columns || $count < 1;

    my $index = $row * $columns + $column;
    return undef if $index >= $count;
    return $index;
}

sub netbar_grid_row_count {
    my ($count, $columns) = @_;
    $count = int($count // 0);
    $columns = int($columns // 1);
    $columns = 1 if $columns < 1;
    return 0 if $count < 1;
    return int(($count + $columns - 1) / $columns);
}

sub netbar_grid_edge_index_for_column {
    my ($count, $columns, $column, $direction) = @_;
    $count = int($count // 0);
    return undef if $count < 1;
    $columns = int($columns // 1);
    $columns = 1 if $columns < 1;
    $column = int($column // 0);
    $column = 0 if $column < 0;
    $column = $columns - 1 if $column >= $columns;

    my $rows = netbar_grid_row_count($count, $columns);
    if (defined($direction) && $direction < 0) {
        for (my $row = $rows - 1; $row >= 0; $row--) {
            my $index = netbar_grid_index_for_position(
                $row, $column, $columns, $count
            );
            return $index if defined $index;
        }
        return $count - 1;
    }

    for my $row (0 .. $rows - 1) {
        my $index = netbar_grid_index_for_position(
            $row, $column, $columns, $count
        );
        return $index if defined $index;
    }
    return 0;
}

sub matrix_pm_page_row {
    my ($network, $direction, $count, $prefix) = @_;
    my $plain = (' ' x ($prefix + 2)) . ($direction < 0 ? '^ ' : 'v ') . $count;
    return {
        key => lc($network) . "\0pw_page\0" . $direction,
        text => '%0%K' . $plain, plain_length => matrix_text_width($plain),
        pm_network => $network, mouse_network => $network,
        mouse_kind => 'pm_page', mouse_direction => $direction,
        mouse_x_start => $prefix,
    };
}

sub netbar_build_sidebar_layout_rows {
    my @networks = netbar_connected_networks();
    my @network_data;
    my $refnum_width = 2;
    my $window_width = netbar_active_width();
    my $channel_prefix_length = $CHANNEL_PREFIX_LENGTH;

    # First collect all networks and determine widths shared by channel rows.
    # QUERY identity is always kept as server tag + nick for exact clicks.
    for my $network (@networks) {
        my @channels = netbar_channel_entries_for_network($network);
        my ($identity_plain, $identity_formatted) =
            netbar_status_identity_data($network);
        my $network_plain = netbar_plain_dynamic_text($network);
        my $network_server = netbar_server_for_network($network);
        my $is_away = $network_server
            && $network_server->{connected}
            && $network_server->{usermode_away}
            ? 1 : 0;
        my $away_reason = $is_away
            ? netbar_plain_dynamic_text($network_server->{away_reason} // '')
            : '';

        for my $entry (@channels) {
            my $digits = length('' . int($entry->{refnum}));
            $refnum_width = $digits if $digits > $refnum_width;
        }
        push @network_data, {
            network              => $network,
            network_plain        => $network_plain,
            identity_plain       => $identity_plain,
            identity_formatted   => $identity_formatted,
            is_active            => netbar_network_is_active($network),
            status_active        => netbar_status_network_is_active($network),
            is_away              => $is_away,
            away_reason          => $away_reason,
            has_status_attention => $status_attention_by_network{lc $network} ? 1 : 0,
            channels             => \@channels,
            pm_page              => matrix_pm_page_data($network),
        };
    }

    # Build equally wide network headers and preliminary channel
    # representations. The header is now its own row; channel rows below it
    # can therefore use the complete terminal width while retaining one shared
    # grid across every network.
    for my $data (@network_data) {
        # The header contains only the network name. Status notifications
        # never animate this row; they light the Status word below it.
        $data->{header_plain} = '[' . $data->{network_plain} . ']';
        $data->{header_formatted} = '%K[' . matrix_network_colour($data->{network})
            . netbar_format_dynamic_text($data->{network_plain}) . '%K]';

        my $status_active = $data->{status_active} ? 1 : 0;
        my $marker_plain = $status_active ? '> ' : '  ';
        my $marker_text = $status_active ? '%B>%K ' : '%K  ';
        my $status_colour = $data->{has_status_attention}
            ? '%M'
            : '%B';
        my $identity_colour = $status_active ? '%W' : '%K';
        my $identity_gap = length($data->{identity_plain}) ? '   ' : '';
        my ($away_plain, $away_formatted) = ('', '');
        if ($data->{is_away}) {
            my $max = Irssi::settings_get_int(
                'network_statusbars_away_max_length'
            );
            $max = 32 if !defined($max) || $max < 1;
            $max = 120 if $max > 120;
            my $reason = netbar_truncate_name(
                $data->{away_reason} // '', $max
            );
            if (length $reason) {
                $away_plain = '  Away: ' . $reason;
                $away_formatted = '%K  Away:%w '
                    . netbar_format_dynamic_text($reason);
            }
            else {
                $away_plain = '  Away';
                $away_formatted = '%K  Away';
            }
        }

        $data->{status_plain} = $marker_plain . 'Status'
            . $identity_gap . $data->{identity_plain};
        $data->{status_formatted} = $marker_text
            . $status_colour . 'Status%K'
            . $identity_gap . $identity_colour
            . $data->{identity_formatted};
        $data->{away_plain} = $away_plain;
        $data->{away_formatted} = $away_formatted;

        $data->{display} = [ map {
            netbar_channel_display_data($_, undef, $refnum_width)
        } @{ $data->{channels} } ];
    }

    # Every network uses the same column count and the same width for every
    # corresponding column. This keeps all channel starts vertically aligned,
    # even though every network name now occupies a separate row above them.
    my $columns = 1;

    my @widths = netbar_shared_column_widths(\@network_data, $columns);
    my $total = $channel_prefix_length + 3 * ($columns - 1) + 1;
    $total += $_ for @widths;

    # If even one shared column cannot fit, truncate all networks to the same
    # maximum item width so their grid still remains aligned.
    if ($columns == 1 && $total > $window_width) {
        my $max_item_width = $window_width - $channel_prefix_length - 1;
        $max_item_width = 4 if $max_item_width < 4;

        for my $data (@network_data) {
            $data->{display} = [ map {
                netbar_channel_display_data($_, $max_item_width, $refnum_width)
            } @{ $data->{channels} } ];
        }
        @widths = netbar_shared_column_widths(\@network_data, $columns);
    }

    my @rows;

    # Sidebar title: keep one empty row above it and one empty row below it.
    # It follows the active network and replaces the old static NETWORK label.
    # The persistent sidebar buffer changes only this row after each MAP update
    # or Ctrl-X/network switch, avoiding a repaint of all channel rows.
    {
        my ($title_text, $title_length) = ('%WNETWORK', length('NETWORK'));
        if (defined &clients_sidebar_heading_data) {
            # One cell is used by the leading margin below. The sidebar's
            # divider is already excluded from $window_width during rendering.
            my $available = $window_width - 1;
            $available = 1 if $available < 1;
            my ($stats_text, $stats_length) =
                clients_sidebar_heading_data($available);
            if (defined($stats_text) && defined($stats_length)) {
                $title_text   = $stats_text;
                $title_length = int($stats_length);
            }
        }

        push @rows, {
            key          => '__sidebar_top_gap__',
            text         => '%0%W',
            plain_length => 0,
        };
        push @rows, {
            key          => '__sidebar_title__',
            text         => '%0%W ' . $title_text,
            plain_length => 1 + $title_length,
        };
        push @rows, {
            key          => '__sidebar_title_gap__',
            text         => '%0%W',
            plain_length => 0,
        };
    }

    for my $network_index (0 .. $#network_data) {
        my $data = $network_data[$network_index];
        my $display_ref = $data->{display};
        my $block_start = scalar @rows;

        # First row of each block: network name and own identity only.
        push @rows, {
            key          => lc($data->{network}) . "\0header",
            text         => '%0%W ' . $data->{header_formatted},
            plain_length => 1 + matrix_text_width($data->{header_plain}),
            mouse_kind   => 'network',
            mouse_network => $data->{network},
            mouse_x_start => 1,
        };

        my $active_index;
        for my $index (0 .. $#{ $data->{channels} }) {
            if ($data->{channels}->[$index]->{active}) {
                $active_index = $index;
                last;
            }
        }

        {
            # Status is informational and deliberately outside channel-arrow
            # navigation. Its short rail is shown only after an explicit
            # Alt+1 or /WINDOW 1. Channel navigation starts below the visual
            # gap, so Status can never look like another channel entry.
            my $status_selected = $data->{is_active}
                && $data->{status_active};
            my $marker_spaces = ' ' x ($channel_prefix_length - 1);
            my $marker_plain = $status_selected
                ? $marker_spaces . '│'
                : '';
            my $marker_text = $status_selected
                ? '%0%W' . $marker_spaces . '%B│'
                : '%0%W';

            push @rows, {
                key          => lc($data->{network}) . "\0header_gap",
                text         => $marker_text,
                plain_length => length($marker_plain),
            };

            my $guide_active = $status_selected ? 1 : 0;
            # When AWAY is visible, extend the selected Status rail through the
            # Status row and terminate it on the dedicated Away row below.
            my $guide_char = $data->{is_away} ? '│' : '└';
            my $prefix_plain = $guide_active
                ? ((' ' x ($channel_prefix_length - 1)) . $guide_char)
                : (' ' x $channel_prefix_length);
            my $prefix_text = $guide_active
                ? ('%0%W' . (' ' x ($channel_prefix_length - 1))
                    . '%B' . $guide_char)
                : ('%0%W' . (' ' x $channel_prefix_length));

            push @rows, {
                key          => lc($data->{network}) . "\0status",
                text         => $prefix_text . $data->{status_formatted},
                plain_length => matrix_text_width($prefix_plain)
                    + matrix_text_width($data->{status_plain}),
                mouse_kind   => 'status',
                mouse_network => $data->{network},
                mouse_x_start => $channel_prefix_length,
            };

            if ($data->{is_away}) {
                my $away_guide_char = '└';
                my $away_prefix_plain = $guide_active
                    ? ((' ' x ($channel_prefix_length - 1))
                        . $away_guide_char)
                    : (' ' x $channel_prefix_length);
                my $away_prefix_text = $guide_active
                    ? ('%0%W' . (' ' x ($channel_prefix_length - 1))
                        . '%B' . $away_guide_char)
                    : ('%0%W' . (' ' x $channel_prefix_length));

                push @rows, {
                    key          => lc($data->{network}) . "\0away",
                    text         => $away_prefix_text
                        . ($data->{away_formatted} // '%K  Away'),
                    plain_length => matrix_text_width($away_prefix_plain)
                        + matrix_text_width($data->{away_plain} // '  Away'),
                    mouse_kind   => 'status',
                    mouse_network => $data->{network},
                    mouse_x_start => $channel_prefix_length,
                };
            }

            # One dedicated spacer separates Status/Away from every #channel.
            # Under a visible Away row it is completely blank, as requested;
            # otherwise an active channel may keep only its independent rail.
            my $channel_selected = $data->{is_active}
                && defined($active_index);
            my $channel_gap_plain = $channel_selected && !$data->{is_away}
                ? $marker_spaces . '│'
                : '';
            my $channel_gap_text = $channel_selected && !$data->{is_away}
                ? '%0%W' . $marker_spaces . '%B│'
                : '%0%W';

            push @rows, {
                key          => lc($data->{network}) . "\0status_gap",
                text         => $channel_gap_text,
                plain_length => length($channel_gap_plain),
            };
        }

        # Following rows: responsive, shared channel grid spanning the full
        # width. The rail reuses the fixed prefix or the existing separator,
        # stays perfectly vertical and terminates as ``└>`` on the active row.
        if (@$display_ref) {
            my $active_column = defined($active_index) ? ($active_index % $columns) : undef;
            my $active_row    = defined($active_index) ? int($active_index / $columns) : undef;
            my $row_count = int((@$display_ref + $columns - 1) / $columns);

            for my $row_index (0 .. $row_count - 1) {
                my @parts;
                my @plain_parts;
                my $first = $row_index * $columns;

                for my $column (0 .. $columns - 1) {
                    my $index = $first + $column;
                    last if $index > $#$display_ref;

                    my $part = $display_ref->[$index];
                    my $padding = ' ' x (
                        $widths[$column] - matrix_text_width($part->{plain})
                    );
                    push @parts, $part->{formatted} . $padding;
                    push @plain_parts, $part->{plain} . $padding;
                }

                my $guide_active = $data->{is_active}
                    && defined($active_column)
                    && defined($active_row)
                    && $row_index <= $active_row;
                my $guide_end = $guide_active && $row_index == $active_row;
                my $guide_char = $guide_end ? '└' : '│';

                # Column one has no normal separator before it, so its rail uses
                # the final cell of the fixed prefix. On the active row the prefix
                # ends in ``└`` and the channel item begins with blue ``>``, giving
                # a compact ``└>``. Earlier rows use ``│`` in exactly the same cell.
                my $prefix_plain = ($guide_active && $active_column == 0)
                    ? ((' ' x ($channel_prefix_length - 1)) . $guide_char)
                    : (' ' x $channel_prefix_length);
                my $prefix_text = ($guide_active && $active_column == 0)
                    ? ('%0%W' . (' ' x ($channel_prefix_length - 1))
                        . '%B' . $guide_char)
                    : '%0%W' . (' ' x $channel_prefix_length);

                my $channel_text = '';
                my $channel_plain = '';
                for my $column (0 .. $#parts) {
                    if ($column > 0) {
                        if ($guide_active && $column == $active_column) {
                            # Keep the separator at three cells. ``  └`` followed
                            # by the channel's blue ``>`` becomes ``  └>`` without
                            # shifting any column; rows above use ``  │``.
                            $channel_text .= '%K  %B' . $guide_char;
                            $channel_plain .= '  ' . $guide_char;
                        }
                        else {
                            $channel_text .= '%K : ';
                            $channel_plain .= ' : ';
                        }
                    }
                    $channel_text .= $parts[$column];
                    $channel_plain .= $plain_parts[$column];
                }

                my %mouse_target;
                my $entry = $data->{channels}->[$first];
                if ($entry && $entry->{server}) {
                    %mouse_target = (
                        mouse_kind       => 'channel',
                        mouse_network    => $data->{network},
                        mouse_tag        => netbar_server_tag($entry->{server}),
                        mouse_name       => $entry->{name} // '',
                        mouse_connection => matrix_connection_identity($entry->{server}),
                        mouse_x_start    => $channel_prefix_length,
                    );
                }

                push @rows, {
                    key          => lc($data->{network}) . "\0channels\0" . $row_index,
                    text         => $prefix_text . $channel_text,
                    plain_length => matrix_text_width($prefix_plain)
                        + matrix_text_width($channel_plain),
                    %mouse_target,
                };
            }
        }

        my $pm_page = $data->{pm_page};
        if (@{ $pm_page->{entries} }) {
            push @rows, {
                key => lc($data->{network}) . "\0pw_gap",
                text => '%0%W', plain_length => 0, pm_network => $data->{network},
            }, {
                key => lc($data->{network}) . "\0pw_header",
                text => '%0%K' . (' ' x ($channel_prefix_length + 2)) . 'PM',
                plain_length => $channel_prefix_length + 4,
                pm_network => $data->{network},
            };
            push @rows, matrix_pm_page_row($data->{network}, -1, $pm_page->{before}, $channel_prefix_length)
                if $pm_page->{before};
            for my $entry (@{ $pm_page->{entries} }) {
                my $query = $entry->{query};
                my $active_window = Irssi::active_win();
                my $selected = $active_window && $query->is_active()
                    && $query->window()->{refnum} == $active_window->{refnum};
                my $prefix = (' ' x $channel_prefix_length) . ($selected ? '> ' : '  ');
                my $available = $window_width - matrix_text_width($prefix) - 1;
                $available = 1 if $available < 1;
                my $rendered = matrix_sidebar_render_activity_entry($entry, $available);
                next unless $rendered;
                push @rows, {
                    key => lc($entry->{tag}) . "\0query\0" . matrix_tag_casefold($entry->{tag}, $entry->{nick}),
                    text => '%0%W' . (' ' x $channel_prefix_length)
                        . ($selected ? '%B> ' : '  ') . $rendered->{text},
                    plain_length => matrix_text_width($prefix . $rendered->{plain}),
                    # Reserve an age suffix even on read rows: minute changes
                    # never resize the sidebar or shift the conversation pane.
                    width_requirement => matrix_text_width($prefix
                        . $entry->{refnum} . '/' . $entry->{plain_nick} . ' (999) · 9999d'),
                    selected => $selected ? 1 : 0,
                    mouse_kind => 'query', mouse_network => $data->{network},
                    pm_network => $data->{network},
                    mouse_tag => $entry->{tag}, mouse_name => $entry->{nick},
                    mouse_connection => matrix_connection_identity($query->{server}),
                    mouse_x_start => $channel_prefix_length,
                };
            }
            push @rows, matrix_pm_page_row($data->{network}, 1, $pm_page->{after}, $channel_prefix_length)
                if $pm_page->{after};
        }
        $rows[$_]->{section_network} = $data->{network}
            for $block_start .. $#rows;

        # One fully black separator row between network blocks.
        if ($network_index < $#network_data) {
            my $next_network = $network_data[$network_index + 1]->{network};
            push @rows, {
                key          => '__network_gap__' . "\0"
                    . lc($data->{network}) . "\0" . lc($next_network),
                text         => '%0%W',
                plain_length => 0,
            };
        }
    }

    return @rows;
}

# Active rendering path: build_sidebar_layout_rows -> sidebar_render ->
# incremental TextBuffer patch. Historical Act/inline helpers above are kept
# callable for compatibility, but do not own current bars or redraw timers.
sub netbar_build_layout_rows {
    return netbar_build_sidebar_layout_rows();
}

sub netbar_hide_standard_identity_items {
    # Compatibility no-op: hiding belongs exclusively to apply_chrome, so a
    # stale external caller cannot hide native bars while /netbars is OFF.
    return;
}

sub netbar_restore_standard_identity_items {
    if (Irssi::settings_get_bool('network_statusbars_enabled')) {
        $netbar_keep_native_identity = 1;
        matrix_with_frozen_refresh(sub {
            matrix_restore_saved_statusbars(qw(window window_inact));
        });
        return;
    }
    return if $netbar_native_ui_restored;
    # Mark first: statusbar commands emit synchronous setup/resize callbacks.
    $netbar_native_ui_restored = 1;
    matrix_with_frozen_refresh(sub {
        matrix_disable_owned_statusbars();
        matrix_restore_saved_statusbars();
    });
}

sub netbar_apply_standard_bar_choice {
    if (!Irssi::settings_get_bool('network_statusbars_enabled')) {
        netbar_restore_standard_identity_items();
    }
}

sub netbar_native_ui_reread {
    return if $matrix_unloading;
    $netbar_native_ui_restored = 0;
    netbar_schedule_refresh();
}

sub netbar_refresh_all {
    # A direct refresh must cancel a refresh which is already queued. Merely
    # forgetting its id leaves a CODE callback registered against the script
    # after /UNLOAD and is the reload-time duplicate-bar/crash race.
    if (defined $refresh_timer) {
        Irssi::timeout_remove($refresh_timer);
        undef $refresh_timer;
    }
    return if $matrix_unloading || $netbar_sidebar_shutting_down;

    unless (Irssi::settings_get_bool('network_statusbars_enabled')) {
        netbar_sidebar_teardown(1) if netbar_sidebar_window();
        netbar_restore_standard_identity_items();
        $netbar_mouse_layout_width = 0;
        $netbar_mouse_layout_height = 0;
        $force_layout = 0;
        netbar_mouse_apply_setting() if defined &netbar_mouse_apply_setting;
        return;
    }

    $netbar_native_ui_restored = 0;
    if (netbar_sidebar_ensure_layout()) {
        $netbar_sidebar_warned = 0;
        netbar_sidebar_render();
        $force_layout = 0;
        netbar_mouse_apply_setting() if defined &netbar_mouse_apply_setting;
        return;
    }

    if (!$netbar_sidebar_warned) {
        Irssi::print(
            'network_statusbars: sidebar creation failed; panel unavailable.',
            Irssi::MSGLEVEL_CLIENTERROR(),
        );
        $netbar_sidebar_warned = 1;
    }

    $force_layout = 0;
    netbar_mouse_apply_setting() if defined &netbar_mouse_apply_setting;
}

sub netbar_run_scheduled_refresh {
    # timeout_add_once has already consumed this id. Clear it before entering
    # the normal refresh path so that path does not try to remove itself.
    undef $refresh_timer;
    netbar_refresh_all();
}

sub netbar_schedule_refresh {
    return if $matrix_unloading || $netbar_sidebar_shutting_down;
    return if defined $refresh_timer;
    $refresh_timer = Irssi::timeout_add_once(
        75,
        'netbar_run_scheduled_refresh',
        '',
    );
}

sub netbar_force_rebuild {
    undef $netbar_mouse_drag;
    return if $matrix_unloading || $netbar_sidebar_shutting_down;
    $force_layout = 1;
    netbar_apply_standard_bar_choice();
    netbar_refresh_all();
}

# ----- Efficient IRC-operator discovery ------------------------------------


sub netbar_oper_wire_outgoing {
    my ($server, $line_ref) = @_;
    return if $matrix_unloading || !$server;
    my $line = ref($line_ref) ? $$line_ref : $line_ref;
    return unless defined($line)
        && $line =~ /\A(?:\@\S+\s+)?WHO\s+:?(\S+)/i;
    my $mask = $1;
    my $tag = netbar_server_tag($server);
    return unless length $tag;
    return if $oper_who_wire_desynced{$tag};
    my $queue = ($oper_who_wire_queue{$tag} ||= []);
    if (@$queue >= 128) {
        # Never guess ownership after losing correlation. User WHO is still
        # passed through, and reconnect creates a fresh bounded request queue.
        $oper_who_wire_desynced{$tag} = 1;
        delete $oper_who_wire_queue{$tag};
        netbar_oper_event_who_error($server, '') if $oper_who_active{$tag};
        return;
    }
    push @$queue, {
        mask => matrix_irc_casefold($server, $mask),
        owned => length($oper_who_sending_identity)
            && $oper_who_sending_identity eq matrix_connection_identity($server)
                ? 1 : 0,
    };
}

sub netbar_oper_wire_incoming {
    my ($server, $line) = @_;
    return if $matrix_unloading || !$server || !defined($line);
    return unless $line =~ /\A(?:\@\S+\s+)?(?::\S+\s+)?(352|315|263|416)\s+(.+)\z/s;
    my ($numeric, $data) = ($1, $2);
    my $tag = netbar_server_tag($server);
    return if $oper_who_wire_desynced{$tag};
    my $queue = $oper_who_wire_queue{$tag};
    return unless $queue && @$queue;
    my $request = $queue->[0];
    if ($numeric eq '352') {
        if ($request->{owned}) {
            if ($oper_who_active{$tag}) {
                netbar_oper_event_who_reply($server, $data);
            }
            elsif (Irssi::settings_get_bool('network_statusbars_oper_quiet')) {
                # A timed-out/unloaded collector is gone, but its late rows
                # still belong to this request, not the user's next WHO.
                Irssi::signal_stop();
            }
        }
        return;
    }
    my @parts = split /\s+/, $data;
    my $target = matrix_irc_casefold($server, $parts[1]);
    $target =~ s/^://;
    my $matched = $target eq $request->{mask}
        || ($request->{owned} && $target =~ /\A(?:0|\*|o)\z/);
    $matched = $target eq 'who' if $numeric eq '263';
    return unless $matched;
    shift @$queue;
    delete $oper_who_wire_queue{$tag} unless @$queue;
    if ($request->{owned} && !$oper_who_active{$tag} && $numeric eq '315') {
        Irssi::signal_stop()
            if Irssi::settings_get_bool('network_statusbars_oper_quiet');
        return;
    }
    return unless $request->{owned} && $oper_who_active{$tag};
    if ($numeric eq '315') {
        netbar_oper_event_who_end($server, $data);
    }
    else {
        # Error lines remain visible; an error for another IRC command never
        # aborts our WHO, and cannot consume another request's queue entry.
        netbar_oper_event_who_error($server, $data);
    }
}

sub netbar_oper_note_failure {
    my ($tag) = @_;
    return unless defined($tag) && length($tag);

    my $failures = ($oper_failures{$tag} || 0) + 1;
    $failures = 6 if $failures > 6;
    $oper_failures{$tag} = $failures;

    my $seconds = 60 * (2 ** ($failures - 1));
    $seconds = 3600 if $seconds > 3600;
    $oper_backoff_until{$tag} = time() + $seconds;
}

sub netbar_oper_timeout {
    my ($tag) = @_;
    return unless defined $tag && length $tag;

    delete $oper_who_active{$tag};
    delete $oper_who_collect{$tag};
    delete $oper_who_timeout_tag{$tag};
    netbar_oper_note_failure($tag);
}

sub netbar_oper_finish {
    my ($server) = @_;
    return unless $server;

    my $tag = netbar_server_tag($server);
    return unless length $tag && $oper_who_active{$tag};

    $oper_nicks_by_tag{$tag} = $oper_who_collect{$tag} || {};
    delete $oper_failures{$tag};
    delete $oper_backoff_until{$tag};
    $oper_membership_generation{$tag} =
        ($oper_membership_generation{$tag} || 1) + 1;
    my $prefix = lc($tag) . "\0";
    delete $oper_channel_cache{$_}
        for grep { index($_, $prefix) == 0 } keys %oper_channel_cache;
    delete $oper_who_active{$tag};
    delete $oper_who_collect{$tag};

    if ($oper_who_timeout_tag{$tag}) {
        Irssi::timeout_remove($oper_who_timeout_tag{$tag});
        delete $oper_who_timeout_tag{$tag};
    }

    netbar_schedule_refresh();
}

sub netbar_oper_event_who_reply {
    my ($server, $data) = @_;
    return unless $server && defined $data;

    my $tag = netbar_server_tag($server);
    return unless length $tag && $oper_who_active{$tag};

    # RPL_WHOREPLY: me channel user host server nick flags :hop realname
    my @parts = split /\s+/, $data;
    return unless @parts >= 7;
    Irssi::signal_stop()
        if Irssi::settings_get_bool('network_statusbars_oper_quiet');

    my $who_nick = $parts[5] // '';
    my $flags    = $parts[6] // '';
    return unless length $who_nick && $flags =~ /\*/;

    my $own = matrix_irc_casefold($server, $server->{nick});
    return if length($own) && matrix_irc_casefold($server, $who_nick) eq $own;
    $oper_who_collect{$tag}->{matrix_irc_casefold($server, $who_nick)} = 1;
}

sub netbar_oper_event_who_end {
    my ($server, $data) = @_;
    return unless $server && defined $data;

    my $tag = netbar_server_tag($server);
    return unless length $tag && $oper_who_active{$tag};

    # Called only for the owned head request after its end mask was checked.
    # Ordinary WHO and native channel synchronization never enter this path.
    Irssi::signal_stop()
        if Irssi::settings_get_bool('network_statusbars_oper_quiet');
    netbar_oper_finish($server);
}

sub netbar_oper_event_who_error {
    my ($server, $data) = @_;
    return unless $server;

    my $tag = netbar_server_tag($server);
    return unless length $tag && $oper_who_active{$tag};

    # Keep the server's error numeric visible for diagnostics.
    if ($oper_who_timeout_tag{$tag}) {
        Irssi::timeout_remove($oper_who_timeout_tag{$tag});
        delete $oper_who_timeout_tag{$tag};
    }
    delete $oper_who_active{$tag};
    delete $oper_who_collect{$tag};
    netbar_oper_note_failure($tag);
}

sub netbar_oper_request_for_server {
    my ($server) = @_;
    return unless Irssi::settings_get_bool('network_statusbars_oper_marker');
    return unless $server && $server->{connected};

    my $tag = netbar_server_tag($server);
    return unless length $tag;
    return if ($oper_backoff_until{$tag} || 0) > time();
    return if $oper_who_active{$tag};
    return if $oper_who_wire_desynced{$tag};
    return if @{$oper_who_wire_queue{$tag} || []};
    return if time() - ($oper_who_last_sent{$tag} || 0) < 2;

    $oper_who_active{$tag}  = 1;
    $oper_who_collect{$tag} = {};

    if ($oper_who_timeout_tag{$tag}) {
        Irssi::timeout_remove($oper_who_timeout_tag{$tag});
    }
    $oper_who_timeout_tag{$tag} = Irssi::timeout_add_once(
        $OPER_WHO_TIMEOUT,
        'netbar_oper_timeout',
        $tag,
    );

    # send_raw_now makes the ownership flag cover the actual wire callback.
    # Background WHO is infrequent and independently rate-limited above. Native
    # and manually queued commands retain their ordinary Irssi flood protection.
    $oper_who_last_sent{$tag} = time();
    $oper_who_sending_identity = matrix_connection_identity($server);
    my $sent = eval { $server->send_raw_now('WHO 0 o'); 1 };
    $oper_who_sending_identity = '';
    netbar_oper_event_who_error($server, '') unless $sent;
}

sub netbar_oper_request_by_tag {
    my ($tag) = @_;
    delete $oper_join_timer_tag{$tag};
    my $server = Irssi::server_find_tag($tag);
    netbar_oper_request_for_server($server) if $server;
}

sub netbar_oper_schedule_for_server {
    my ($server, $delay_ms) = @_;
    return unless $server;

    my $tag = netbar_server_tag($server);
    return unless length $tag;
    $delay_ms = 4000 unless defined($delay_ms) && $delay_ms > 0;

    if ($oper_join_timer_tag{$tag}) {
        Irssi::timeout_remove($oper_join_timer_tag{$tag});
    }
    $oper_join_timer_tag{$tag} = Irssi::timeout_add_once(
        $delay_ms,
        'netbar_oper_request_by_tag',
        $tag,
    );
}

sub netbar_oper_invalidate_membership {
    my ($server) = @_;
    return unless $server;

    my $tag = netbar_server_tag($server);
    return unless length $tag;

    $oper_membership_generation{$tag} =
        ($oper_membership_generation{$tag} || 1) + 1;

    my $prefix = lc($tag) . "\0";
    delete $oper_channel_cache{$_}
        for grep { index($_, $prefix) == 0 } keys %oper_channel_cache;

    netbar_schedule_refresh();
}

sub netbar_oper_message_join {
    my ($server) = @_;
    # Hundreds of JOINs simply keep moving one timer; one WHO is sent after the
    # burst, never hundreds of WHOIS requests.
    netbar_oper_schedule_for_server($server, 4000);
}

sub netbar_oper_membership_message {
    my ($server) = @_;
    netbar_oper_invalidate_membership($server) if $server;
    netbar_oper_schedule_for_server($server, 2500) if $server;
}

sub netbar_oper_server_connected {
    my ($server) = @_;
    netbar_oper_schedule_for_server($server, 5000);
}

sub netbar_oper_server_disconnected {
    my ($server) = @_;
    my $tag = netbar_server_tag($server);
    return unless length $tag;

    delete $oper_who_wire_queue{$tag};
    delete $oper_who_wire_desynced{$tag};
    delete $oper_who_last_sent{$tag};
    my $away_key = netbar_away_session_key($server);
    if (length $away_key) {
        delete $away_mention_sessions{$away_key};
        delete $auto_away_servers{$away_key};
    }
    my $status_network = netbar_network_name_for_server($server);
    if (length $status_network) {
        my $prefix = lc($status_network) . "\0";
        delete $message_activity{$_}
            for grep { index($_, $prefix) == 0 } keys %message_activity;
        delete $nick_attention{$_}
            for grep { index($_, $prefix) == 0 } keys %nick_attention;
        delete $channel_unread_count{$_}
            for grep { index($_, $prefix) == 0 } keys %channel_unread_count;
    }
    delete $status_attention_quiet_until_by_network{lc $status_network}
        if length $status_network;

    Irssi::timeout_remove($oper_who_timeout_tag{$tag})
        if $oper_who_timeout_tag{$tag};
    Irssi::timeout_remove($oper_join_timer_tag{$tag})
        if $oper_join_timer_tag{$tag};

    delete $oper_nicks_by_tag{$tag};
    delete $oper_membership_generation{$tag};
    delete $oper_failures{$tag};
    delete $oper_backoff_until{$tag};
    my $oper_cache_prefix = lc($tag) . "\0";
    delete $oper_channel_cache{$_}
        for grep { index($_, $oper_cache_prefix) == 0 } keys %oper_channel_cache;
    delete $oper_who_collect{$tag};
    delete $oper_who_active{$tag};
    delete $oper_who_timeout_tag{$tag};
    delete $oper_join_timer_tag{$tag};
    netbar_oper_invalidate_membership();
}

sub netbar_oper_periodic_refresh {
    for my $server (Irssi::servers()) {
        netbar_oper_request_for_server($server);
    }
}

sub netbar_oper_restart_periodic_timer {
    if ($oper_periodic_timer_tag) {
        Irssi::timeout_remove($oper_periodic_timer_tag);
        $oper_periodic_timer_tag = undef;
    }

    my $seconds = Irssi::settings_get_int('network_statusbars_oper_refresh_seconds');
    $seconds = 300 if $seconds < 60;
    $oper_periodic_timer_tag = Irssi::timeout_add(
        $seconds * 1000,
        'netbar_oper_periodic_refresh',
        0,
    );
}

sub netbar_setup_changed {
    undef $netbar_mouse_chat_click;
    undef $netbar_mouse_drag;
    netbar_muted_channels_load();
    netbar_oper_restart_periodic_timer();
    netbar_auto_away_restart_timer();
    netbar_mouse_apply_setting() if defined &netbar_mouse_apply_setting;
    netbar_sidebar_schedule_width_recheck();
    netbar_schedule_refresh();
}

sub netbar_command_netbars {
    my ($data) = @_;
    $data //= '';
    $data =~ s/^\s+//;
    $data =~ s/\s+$//;
    my $command = lc $data;

    if ($command eq '' || $command eq 'refresh') {
        $force_layout = 1 if $command eq '';
        netbar_refresh_all();
        Irssi::print('network_statusbars: refreshed.');
        return;
    }

    if ($command eq 'on') {
        Irssi::settings_set_bool('network_statusbars_enabled', 1);
        $force_layout = 1;
        netbar_apply_standard_bar_choice();
        netbar_refresh_all();
        Irssi::print('network_statusbars: enabled.');
        return;
    }

    if ($command eq 'sidebar') {
        Irssi::settings_set_bool('network_statusbars_enabled', 1);
        $force_layout = 1;
        matrix_apply_dynamic_prompt() if defined &matrix_apply_dynamic_prompt;
        netbar_refresh_all();
        clients_configure_statusbar() if defined &clients_configure_statusbar;
        Irssi::print('network_statusbars: left sidebar enabled.');
        return;
    }

    if ($command =~ /^mouse(?:\s+(.*))?$/) {
        my $arg = defined($1) && length($1) ? $1 : 'status';
        netbar_command_mouse($arg);
        return;
    }

    if ($command eq 'off') {
        Irssi::settings_set_bool('network_statusbars_enabled', 0);
        netbar_refresh_all();
        netbar_restore_standard_identity_items();
        Irssi::print('network_statusbars: disabled; standard identity restored.');
        return;
    }

    if ($command eq 'rebuild') {
        netbar_force_rebuild();
        Irssi::print('network_statusbars: rebuilt.');
        return;
    }

    if ($command eq 'opers') {
        # Manual refresh is quiet as well; WHO replies are consumed by the
        # background handlers and nothing is printed into the Status window.
        netbar_oper_periodic_refresh();
        return;
    }

    if ($command eq 'clean') {
        Irssi::settings_set_bool('network_statusbars_replace_standard', 0);
        netbar_restore_standard_identity_items();
        netbar_sidebar_reapply_chrome();
        Irssi::print('network_statusbars: native Irssi identity bar retained.');
        return;
    }

    if ($command eq 'restore') {
        Irssi::settings_set_bool('network_statusbars_replace_standard', 0);
        netbar_restore_standard_identity_items();
        netbar_sidebar_reapply_chrome();
        Irssi::print('network_statusbars: standard nick/window items restored.');
        return;
    }

    Irssi::print('Usage: /netbars [sidebar|on|off|mouse [on|off|status]|refresh|rebuild|opers|clean|restore]');
}

Irssi::settings_add_bool(
    'network_statusbars',
    'network_statusbars_enabled',
    1,
);
Irssi::settings_add_bool(
    'network_statusbars',
    'network_statusbars_sidebar_auto_width',
    1,
);
Irssi::settings_add_int(
    'network_statusbars',
    'network_statusbars_sidebar_width',
    34,
);
Irssi::settings_add_bool(
    'network_statusbars',
    'network_statusbars_replace_standard',
    0,
);
Irssi::settings_add_str(
    'network_statusbars',
    'network_statusbars_order',
    'IRCnet IRCnet2',
);
Irssi::settings_add_bool(
    'network_statusbars',
    'network_statusbars_oper_marker',
    1,
);
Irssi::settings_add_bool(
    'network_statusbars',
    'network_statusbars_oper_quiet',
    1,
);
Irssi::settings_add_int(
    'network_statusbars',
    'network_statusbars_oper_refresh_seconds',
    300,
);
Irssi::settings_add_int(
    'network_statusbars',
    'network_statusbars_away_max_length',
    32,
);
Irssi::settings_add_bool(
    'network_statusbars',
    'network_statusbars_away_notice_enabled',
    1,
);
Irssi::settings_add_bool(
    'network_statusbars',
    'network_statusbars_away_notice_include_reason',
    0,
);
Irssi::settings_add_bool(
    'network_statusbars',
    'network_statusbars_away_store_message_text',
    1,
);
Irssi::settings_add_int(
    'network_statusbars',
    'network_statusbars_away_sender_limit',
    256,
);
Irssi::settings_add_time(
    'network_statusbars',
    'network_statusbars_away_sender_ttl',
    '6h',
);
Irssi::settings_add_time(
    'network_statusbars',
    'network_statusbars_away_notice_interval',
    '60min',
);
Irssi::settings_add_int(
    'network_statusbars',
    'network_statusbars_away_notice_burst_limit',
    10,
);
Irssi::settings_add_time(
    'network_statusbars',
    'network_statusbars_away_notice_burst_window',
    '60sec',
);
Irssi::settings_add_int(
    'network_statusbars',
    'network_statusbars_away_spam_limit',
    5,
);
Irssi::settings_add_time(
    'network_statusbars',
    'network_statusbars_away_spam_window',
    '60sec',
);
Irssi::settings_add_int(
    'network_statusbars',
    'network_statusbars_away_spam_total_limit',
    20,
);
Irssi::settings_add_int(
    'network_statusbars',
    'network_statusbars_away_mass_unique_limit',
    20,
);
Irssi::settings_add_time(
    'network_statusbars',
    'network_statusbars_away_mass_window',
    '10sec',
);
Irssi::settings_add_int(
    'network_statusbars',
    'network_statusbars_away_report_max_lines',
    200,
);
Irssi::settings_add_bool(
    'network_statusbars',
    'network_statusbars_auto_away_enabled',
    0,
);
Irssi::settings_add_time(
    'network_statusbars',
    'network_statusbars_auto_away_idle',
    '60min',
);
Irssi::settings_add_int(
    'network_statusbars',
    'network_statusbars_behavior_defaults_revision',
    0,
);
Irssi::settings_add_bool(
    'network_statusbars',
    'network_statusbars_mute_autosave',
    0,
);
Irssi::settings_add_str(
    'network_statusbars',
    'network_statusbars_muted_channels',
    '',
);
Irssi::settings_add_bool(
    'network_statusbars',
    'matrix_mouse_enabled',
    1,
);
Irssi::settings_add_int(
    'network_statusbars',
    'matrix_mouse_offset',
    2,
);
Irssi::settings_add_bool(
    'network_statusbars',
    'matrix_mouse_confirm_close',
    1,
);
Irssi::settings_add_bool(
    'network_statusbars',
    'matrix_enabled',
    1,
);
Irssi::settings_add_bool(
    'network_statusbars',
    'matrix_screen_transition_enabled',
    0,
);
Irssi::settings_add_str(
    'network_statusbars',
    'matrix_transition_mode',
    'window',
);
Irssi::settings_add_int(
    'network_statusbars',
    'matrix_duration_ms',
    360,
);
Irssi::settings_add_str(
    'network_statusbars',
    'matrix_cursor_style',
    'block',
);
Irssi::settings_add_bool(
    'network_statusbars',
    'matrix_screen_input_enabled',
    0,
);
Irssi::settings_add_bool(
    'network_statusbars',
    'matrix_input_enabled',
    1,
);
Irssi::settings_add_int(
    'network_statusbars',
    'matrix_input_flash_ms',
    35,
);
Irssi::settings_add_bool(
    'network_statusbars',
    'matrix_input_paste_bypass',
    1,
);

netbar_apply_behavior_defaults_migration();
netbar_restore_reload_state();

# Apply the cursor style selected with /matrix cursor. Script unload restores
# the terminal's configured/default cursor.
netbar_matrix_input_cursor_apply();
netbar_matrix_screen_attach_watch_start();

netbar_muted_channels_load();

Irssi::signal_add_last('message public',         \&netbar_signal_channel_message);
Irssi::signal_add_last('message irc action',     \&netbar_signal_channel_action);
Irssi::signal_add_last('message own_public',     \&netbar_auto_away_channel_activity);
Irssi::signal_add_last('message irc own_action', \&netbar_auto_away_channel_activity);
Irssi::signal_add_last('message own_action',     \&netbar_auto_away_channel_activity);
Irssi::signal_add_first('send text',             \&netbar_auto_away_send_text_activity);
Irssi::signal_add_first('command me',             \&netbar_auto_away_command_action_activity);
Irssi::signal_add_first('command action',         \&netbar_auto_away_command_action_activity);
Irssi::signal_add_last('print text',             \&netbar_signal_status_print);
Irssi::signal_add_last('window changed',             \&netbar_signal_window_focus_changed);
Irssi::signal_add_last('window changed automatic',   \&netbar_signal_window_focus_changed);
Irssi::signal_add_last('window item changed',        \&netbar_signal_window_focus_changed);
Irssi::signal_add_last('gui window resized',          \&netbar_signal_gui_window_resized);
Irssi::signal_add_last('channel created',            \&netbar_signal_channel_ready);
Irssi::signal_add_last('channel joined',             \&netbar_signal_channel_ready);
Irssi::signal_add_last('channel destroyed',          \&netbar_signal_channel_gone);
Irssi::signal_add_last('window destroyed',           \&netbar_signal_window_destroyed);
Irssi::signal_add_last('message join',                \&netbar_oper_message_join);
Irssi::signal_add_last('message part',                \&netbar_oper_membership_message);
Irssi::signal_add_last('message quit',                \&netbar_oper_membership_message);
Irssi::signal_add_last('message nick',                \&netbar_oper_membership_message);
Irssi::signal_add_last('message nick',                \&netbar_signal_query_nick_changed);
Irssi::signal_add_last('query destroyed',             \&netbar_signal_query_destroyed);
Irssi::signal_add_last('server connected',            \&netbar_oper_server_connected);
Irssi::signal_add_last('server disconnected',         \&netbar_oper_server_disconnected);
Irssi::signal_add_last('server outgoing modify', \&netbar_oper_wire_outgoing);
Irssi::signal_add_first('server incoming', \&netbar_oper_wire_incoming);

for my $signal (
    'channel mode changed',
    'nick mode changed',
    'server nick changed',
    'user mode changed',
    'window item new',
    'window item remove',
    'window item moved',
    'window item server changed',
    'window refnum changed',
    'message notice',
) {
    Irssi::signal_add_last($signal, \&netbar_schedule_refresh);
}
Irssi::signal_add_last('away mode changed', \&netbar_signal_away_mode_changed);
Irssi::signal_add_last('setup changed', \&netbar_setup_changed);
Irssi::signal_add_last('setup reread', \&netbar_native_ui_reread);

Irssi::settings_add_bool('matrix', 'matrix_order_autosave', 1);
Irssi::command_bind('matrixundo', \&netbar_command_undo);
Irssi::command_bind('matrixfind', \&netbar_command_find);
for my $signal ('window refnum changed', 'window destroyed',
                'window item moved', 'channel destroyed',
                'server disconnected', 'setup reread') {
    Irssi::signal_add_first($signal, \&netbar_channel_swap_invalidate);
}

Irssi::command_bind('netbars', \&netbar_command_netbars);
Irssi::command_bind('gaway',   \&netbar_command_global_away);
Irssi::command_bind('autoaway', \&netbar_command_autoaway);
Irssi::signal_add_first('command away', \&netbar_command_single_away);
Irssi::command_bind('mute',     \&netbar_command_mute);
Irssi::command_bind('unmute',   \&netbar_command_unmute);
Irssi::command_bind('matrixnetnext', \&netbar_command_next_network);
Irssi::command_bind('matrixnavleft',  \&netbar_command_nav_left);
Irssi::command_bind('matrixnavright', \&netbar_command_nav_right);
Irssi::command_bind('matrixnavup',    \&netbar_command_nav_up);
Irssi::command_bind('matrixnavdown',  \&netbar_command_nav_down);
Irssi::command_bind('matrixmouse',    \&netbar_command_mouse);
Irssi::command_bind('matrix',      \&netbar_command_matrix);
Irssi::signal_add_first('command join', \&netbar_matrix_command_join_transition);
Irssi::signal_add_first('command j',    \&netbar_matrix_command_join_transition);

Irssi::signal_add_first('gui key pressed', \&netbar_matrix_input_key_hook);
netbar_mouse_install();
# Replace the default next_window_item action. In Status 1 the command calls
# the native action; on channels/QUERY it cycles between connected networks.
Irssi::command('^bind ^X command MATRIXNETNEXT');

# Irssi names Alt-arrow keys mleft/mright/mup/mdown. Override their default
# split-window actions with navigation matching the visible MATRIX interface.
netbar_install_navigation_bindings();

netbar_remember_active_item();
netbar_apply_standard_bar_choice();
netbar_refresh_all();
netbar_oper_restart_periodic_timer();
netbar_auto_away_restart_timer();
for my $server (Irssi::servers()) {
    netbar_oper_schedule_for_server($server, 2500);
    netbar_away_session_for_server($server, 1)
        if netbar_away_server_is_active($server);
}

sub netbar_module_unload {
    Irssi::signal_remove('setup reread', \&netbar_native_ui_reread);
    $netbar_sidebar_shutting_down = 1;
    undef $netbar_channel_swap_undo;
    Irssi::command_unbind('matrixundo', \&netbar_command_undo);
    Irssi::command_unbind('matrixfind', \&netbar_command_find);
    for my $signal ('window refnum changed', 'window destroyed',
                    'window item moved', 'channel destroyed',
                    'server disconnected', 'setup reread') {
        Irssi::signal_remove($signal, \&netbar_channel_swap_invalidate);
    }

    # Detach every event source before issuing WINDOW/STATUSBAR commands.
    # Those commands synchronously emit focus, item and resize signals; leaving
    # even one MATRIX handler connected lets unload rebuild the split while it
    # is being closed and is the reload-time crash/reentrancy path.
    for my $binding (
        [ 'message public',           \&netbar_signal_channel_message ],
        [ 'message irc action',       \&netbar_signal_channel_action ],
        [ 'message own_public',       \&netbar_auto_away_channel_activity ],
        [ 'message irc own_action',   \&netbar_auto_away_channel_activity ],
        [ 'message own_action',       \&netbar_auto_away_channel_activity ],
        [ 'send text',                \&netbar_auto_away_send_text_activity ],
        [ 'command me',               \&netbar_auto_away_command_action_activity ],
        [ 'command action',           \&netbar_auto_away_command_action_activity ],
        [ 'print text',               \&netbar_signal_status_print ],
        [ 'window changed',           \&netbar_signal_window_focus_changed ],
        [ 'window changed automatic', \&netbar_signal_window_focus_changed ],
        [ 'window item changed',      \&netbar_signal_window_focus_changed ],
        [ 'gui window resized',       \&netbar_signal_gui_window_resized ],
        [ 'channel created',          \&netbar_signal_channel_ready ],
        [ 'channel joined',           \&netbar_signal_channel_ready ],
        [ 'channel destroyed',        \&netbar_signal_channel_gone ],
        [ 'window destroyed',         \&netbar_signal_window_destroyed ],
        [ 'message join',             \&netbar_oper_message_join ],
        [ 'message part',             \&netbar_oper_membership_message ],
        [ 'message quit',             \&netbar_oper_membership_message ],
        [ 'message nick',             \&netbar_oper_membership_message ],
        [ 'message nick',             \&netbar_signal_query_nick_changed ],
        [ 'query destroyed',          \&netbar_signal_query_destroyed ],
        [ 'server connected',         \&netbar_oper_server_connected ],
        [ 'server disconnected',      \&netbar_oper_server_disconnected ],
        [ 'server outgoing modify',   \&netbar_oper_wire_outgoing ],
        [ 'server incoming',          \&netbar_oper_wire_incoming ],
        [ 'away mode changed',         \&netbar_signal_away_mode_changed ],
        [ 'setup changed',             \&netbar_setup_changed ],
        [ 'command away',              \&netbar_command_single_away ],
        [ 'command join',              \&netbar_matrix_command_join_transition ],
        [ 'command j',                 \&netbar_matrix_command_join_transition ],
        [ 'gui key pressed',           \&netbar_matrix_input_key_hook ],
    ) {
        Irssi::signal_remove($binding->[0], $binding->[1]);
    }
    for my $signal (
        'channel mode changed',
        'nick mode changed',
        'server nick changed',
        'user mode changed',
        'window item new',
        'window item remove',
        'window item moved',
        'window item server changed',
        'window refnum changed',
        'message notice',
    ) {
        Irssi::signal_remove($signal, \&netbar_schedule_refresh);
    }
    for my $binding (
        [ 'netbars',        \&netbar_command_netbars ],
        [ 'gaway',          \&netbar_command_global_away ],
        [ 'autoaway',       \&netbar_command_autoaway ],
        [ 'mute',           \&netbar_command_mute ],
        [ 'unmute',         \&netbar_command_unmute ],
        [ 'matrixnetnext',  \&netbar_command_next_network ],
        [ 'matrixnavleft',  \&netbar_command_nav_left ],
        [ 'matrixnavright', \&netbar_command_nav_right ],
        [ 'matrixnavup',    \&netbar_command_nav_up ],
        [ 'matrixnavdown',  \&netbar_command_nav_down ],
        [ 'matrixmouse',    \&netbar_command_mouse ],
        [ 'matrix',         \&netbar_command_matrix ],
    ) {
        Irssi::command_unbind($binding->[0], $binding->[1]);
    }

    netbar_matrix_cancel(0);
    netbar_matrix_input_cancel();
    netbar_matrix_screen_attach_watch_stop();
    netbar_matrix_input_cursor_restore();
    netbar_mouse_uninstall();

    for my $timer (
        $netbar_sidebar_focus_timer,
        $netbar_sidebar_normalise_timer,
        $netbar_sidebar_recovery_timer,
        $netbar_sidebar_width_timer,
    ) {
        Irssi::timeout_remove($timer) if defined $timer;
    }
    $netbar_sidebar_focus_timer = undef;
    $netbar_sidebar_normalise_timer = undef;
    $netbar_sidebar_recovery_timer = undef;
    $netbar_sidebar_width_timer = undef;
    # Always run teardown: the split may already be gone while its persistent
    # statusbar nodes are still enabled.
    netbar_sidebar_teardown(1);
    netbar_sidebar_remove_black_theme_file();

    # Restore the exact navigation bindings which existed before this instance.
    matrix_restore_saved_key_bindings();
    netbar_capture_reload_state();
    %oper_who_wire_queue = ();
    %oper_who_wire_desynced = ();
    %oper_who_last_sent = ();
    $oper_who_sending_identity = '';
    %last_item_by_network = ();
    %away_mention_sessions = ();
    %auto_away_servers = ();
    %status_attention_quiet_until_by_network = ();
    $status_attention_suppression_depth = 0;
    %muted_channels = ();
    %channel_unread_count = ();

    Irssi::timeout_remove($refresh_timer) if defined $refresh_timer;
    Irssi::timeout_remove($auto_away_timer_tag) if $auto_away_timer_tag;
    Irssi::timeout_remove($oper_periodic_timer_tag) if $oper_periodic_timer_tag;
    undef $refresh_timer;
    undef $auto_away_timer_tag;
    undef $oper_periodic_timer_tag;

    for my $tag (keys %oper_who_timeout_tag) {
        Irssi::timeout_remove($oper_who_timeout_tag{$tag})
            if $oper_who_timeout_tag{$tag};
    }
    for my $tag (keys %oper_join_timer_tag) {
        Irssi::timeout_remove($oper_join_timer_tag{$tag})
            if $oper_join_timer_tag{$tag};
    }

}

1;
}

# ============================================================================
# clients_activity.pl
# ============================================================================
{
Irssi::settings_add_bool('clients_activity', 'clients_map_background_enabled', 1);
Irssi::settings_add_time('clients_activity', 'clients_map_background_interval', '5min');
my $MAP_TIMEOUT  = 30_000;

# One independent MAP baseline and set of counters per connected server tag.
# The statusbar displays the state belonging to the server of the active
# channel/query, so switching IRCnet <-> IRCnet2 switches the figures instantly.
my %clients_state;
my $periodic_timer_tag;
my %clients_setup_timer_tag;


sub clients_background_interval_ms {
    my $milliseconds = Irssi::settings_get_time(
        'clients_map_background_interval'
    );
    $milliseconds = 300_000 if !defined($milliseconds) || $milliseconds < 60_000;
    $milliseconds = 3_600_000 if $milliseconds > 3_600_000;
    return int($milliseconds);
}

sub clients_restart_periodic_timer {
    if ($periodic_timer_tag) {
        Irssi::timeout_remove($periodic_timer_tag);
        undef $periodic_timer_tag;
    }
    return unless Irssi::settings_get_bool('clients_map_background_enabled');

    $periodic_timer_tag = Irssi::timeout_add(
        clients_background_interval_ms(),
        'clients_periodic_map',
        0,
    );
}

sub clients_note_map_failure {
    my ($state) = @_;
    return unless $state;

    my $failures = int($state->{map_failures} // 0) + 1;
    $failures = 6 if $failures > 6;
    $state->{map_failures} = $failures;

    my $seconds = 60 * (2 ** ($failures - 1));
    $seconds = 3600 if $seconds > 3600;
    $state->{map_backoff_until} = time() + $seconds;
}

sub clients_server_tag {
    my ($server) = @_;
    return '' unless $server;
    return defined($server->{tag}) ? $server->{tag} : '';
}

sub clients_network_name {
    my ($server) = @_;
    return '' unless $server;

    return netbar_network_name_for_server($server);
}

sub clients_server_is_mappable {
    my ($server) = @_;

    # MAP is attempted on every connected network. Some IRCds allow it for
    # ordinary users, while others may reject it; a missing/failed snapshot is
    # simply left as unknown and retried by the normal periodic timer.
    return ($server && $server->{connected}) ? 1 : 0;
}

sub clients_state_for_server {
    my ($server, $create) = @_;
    return undef unless $server;

    my $tag = clients_server_tag($server);
    return undef if $tag eq '';

    if ($create && !exists $clients_state{$tag}) {
        $clients_state{$tag} = {
            tag               => $tag,
            network           => clients_network_name($server),
            users_total       => -1,
            connections       => 0,
            quits             => 0,
            map_active        => 0,
            map_desynced      => 0,
            map_failures      => 0,
            map_backoff_until => 0,
            map_sum           => 0,
            map_lines         => 0,
            map_current       => {},
            map_previous      => {},
            have_previous_map => 0,
            map_timeout_tag        => 0,
            manual_map_active      => 0,
            manual_map_timeout_tag => 0,
            pending_manual_timer_tag => 0,
            manual_map_pending_raw => '',
        };
    }

    my $state = $clients_state{$tag};
    if ($state) {
        $state->{network} = clients_network_name($server);
    }
    return $state;
}

sub clients_active_server {
    my $window = Irssi::active_win();
    if ($window) {
        my $item = $window->{active};
        return $item->{server} if $item && $item->{server};
        return $window->{active_server} if $window->{active_server};
    }

    my $server = eval { Irssi::active_server() };
    return $server if $server;

    return undef;
}

sub clients_redraw_statusbar {
    return if $matrix_unloading;
    netbar_schedule_refresh()
        if defined(&netbar_schedule_refresh);
}

sub clients_sidebar_heading_data {
    my ($max_plain_length) = @_;
    $max_plain_length = int($max_plain_length // 0);

    my $server = clients_active_server();
    my $state = clients_state_for_server($server, 0);
    my $current = $state && $state->{users_total} >= 0
        ? $state->{users_total}
        : '?';
    my $net = $state
        ? int($state->{connections} // 0) - int($state->{quits} // 0)
        : 0;
    my $net_text = $net >= 0 ? "+$net" : "$net";

    my $plain = 'Network:' . $current
        . ' NET:' . $net_text;
    my $format = '%WNetwork:%K' . $current
        . ' %WNET:%K' . $net_text;

    return ($format, length($plain))
        if $max_plain_length < 1 || length($plain) <= $max_plain_length;

    my $clipped = substr($plain, 0, $max_plain_length);
    return ('%W' . $clipped, length($clipped));
}



sub clients_configure_statusbar {
    clients_redraw_statusbar();
    Irssi::command('^redraw');
}

sub clients_map_payload {
    my ($data) = @_;
    return '' unless defined $data;

    $data =~ s/^\S+\s+//;
    $data =~ s/^://;
    return $data;
}

# Manual MAP replies are printed explicitly into Status window 1. Relying on
# Irssi's native numeric renderer after sending MAP with send_raw() is not
# portable: some builds display 015/017/018 only when their own /MAP command
# created the request context. Printing here also guarantees that the same
# callbacks which hide automatic snapshots cannot accidentally hide a manual
# request.
sub clients_print_manual_map_line {
    my ($server, $data) = @_;

    my $line = clients_map_payload($data);
    return if !defined($line) || $line eq '';

    $line =~ s/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]//g;
    $line =~ s/%/%%/g;

    my $window = eval { Irssi::window_find_refnum(1) };
    if ($window) {
        $window->print($line, Irssi::MSGLEVEL_CRAP());
    }
    else {
        Irssi::print($line, Irssi::MSGLEVEL_CRAP());
    }
}

sub clients_clear_active_map {
    my ($state) = @_;
    return unless $state;

    $state->{map_active}  = 0;
    $state->{map_sum}     = 0;
    $state->{map_lines}   = 0;
    $state->{map_current} = {};
}

sub clients_map_timeout {
    my ($tag) = @_;
    my $state = $clients_state{$tag};
    return unless $state;

    $state->{map_timeout_tag} = 0;
    return unless $state->{map_active};

    clients_clear_active_map($state);
    clients_note_map_failure($state);
    # After a timeout we no longer know where the old numeric stream ends.
    # Fail closed: do not correlate another MAP until an eventual 017 gives us
    # a clean boundary, or reconnect replaces this per-server state entirely.
    $state->{map_desynced} = 1;

    if ($state->{pending_manual_timer_tag}) {
        Irssi::timeout_remove($state->{pending_manual_timer_tag});
        $state->{pending_manual_timer_tag} = 0;
    }
    if (length($state->{manual_map_pending_raw} // '')) {
        $state->{manual_map_pending_raw} = '';
        Irssi::print(
            'MAP: background request timed out; queued /MAP was cancelled. Retry after the old MAP reply finishes or reconnect.',
            Irssi::MSGLEVEL_CLIENTNOTICE(),
        );
    }
}

sub clients_manual_map_timeout {
    my ($tag) = @_;
    my $state = $clients_state{$tag};
    return unless $state;

    $state->{manual_map_timeout_tag} = 0;
    return unless $state->{manual_map_active};
    $state->{manual_map_active} = 0;
    $state->{map_desynced} = 1;

    if ($state->{pending_manual_timer_tag}) {
        Irssi::timeout_remove($state->{pending_manual_timer_tag});
        $state->{pending_manual_timer_tag} = 0;
    }
    $state->{manual_map_pending_raw} = '';
    Irssi::print(
        'MAP: request timed out; MATRIX MAP tracking is paused until the server closes the old MAP reply or reconnect.',
        Irssi::MSGLEVEL_CLIENTNOTICE(),
    );
}

sub clients_mark_manual_map {
    my ($server) = @_;
    return undef unless $server && $server->{connected};

    my $state = clients_state_for_server($server, 1);
    return undef unless $state;

    if ($state->{manual_map_timeout_tag}) {
        Irssi::timeout_remove($state->{manual_map_timeout_tag});
        $state->{manual_map_timeout_tag} = 0;
    }

    $state->{manual_map_active} = 1;
    $state->{manual_map_timeout_tag} = Irssi::timeout_add_once(
        $MAP_TIMEOUT,
        'clients_manual_map_timeout',
        $state->{tag},
    );

    return $state;
}

# Used only for a manual command which had to wait for an already-running quiet
# snapshot. Immediate interactive /MAP commands are sent once by Irssi's native
# command handler; this fallback sends the queued raw command after the hidden
# request has ended.
sub clients_start_manual_map {
    my ($server, $raw) = @_;
    return unless $server && $server->{connected};
    return unless defined($raw) && $raw =~ /^MAP(?:\s|$)/i;
    return unless clients_mark_manual_map($server);

    $server->send_raw($raw);
}

sub clients_run_pending_manual_map {
    my ($tag) = @_;
    my $state = $clients_state{$tag};
    return unless $state;
    return if $state->{map_desynced}
        || $state->{map_active}
        || $state->{manual_map_active};

    my $raw = $state->{manual_map_pending_raw} // '';
    $state->{manual_map_pending_raw} = '';
    return if $raw eq '';

    my $server = Irssi::server_find_tag($tag);
    clients_start_manual_map($server, $raw) if $server;
}

sub clients_run_pending_manual_map_delayed {
    my ($tag) = @_;
    my $state = $clients_state{$tag};
    $state->{pending_manual_timer_tag} = 0 if $state;
    return if $matrix_unloading;
    clients_run_pending_manual_map($tag);
}

sub clients_schedule_pending_manual_map {
    my ($state) = @_;
    return unless $state && length($state->{manual_map_pending_raw} // '');
    Irssi::timeout_remove($state->{pending_manual_timer_tag})
        if $state->{pending_manual_timer_tag};
    $state->{pending_manual_timer_tag} = Irssi::timeout_add_once(
        50,
        'clients_run_pending_manual_map_delayed',
        $state->{tag},
    );
}

sub clients_queue_or_start_manual_map {
    my ($server, $raw) = @_;
    return 1 unless $server && $server->{connected};

    my $state = clients_state_for_server($server, 1);
    return 1 unless $state;

    if ($state->{map_active} || $state->{manual_map_active}) {
        # Serialize manual requests behind any MAP stream already owned by
        # MATRIX. A single pending slot is enough; a newer explicit /MAP
        # replaces an older queued one instead of creating overlapping streams.
        $state->{manual_map_pending_raw} = $raw;
        return 1;
    }

    # Mark the request before Irssi's original /MAP handler sends it. Returning
    # zero tells the caller not to stop the command signal.
    clients_mark_manual_map($server);
    return 0;
}

sub clients_cmd_manual_map {
    my ($data, $server, $window_item) = @_;
    $data = '' unless defined $data;

    # /COMMAND handlers receive only the arguments, without the command name.
    # Strip line breaks defensively before constructing the raw IRC command.
    $data =~ s/[\r\n].*\z//s;
    $data =~ s/^\s+|\s+$//g;

    $server ||= clients_active_server();

    # This callback runs before Irssi's original /MAP handler. For an immediate
    # request it only marks the reply as manual and lets the native handler send
    # exactly one command. If a quiet snapshot is still running, it queues the
    # request and stops the native handler until that snapshot has finished.
    if (!$server || !$server->{connected}) {
        Irssi::signal_stop();
        Irssi::print(
            'MAP: no active server connection.',
            Irssi::MSGLEVEL_CLIENTERROR(),
        );
        return;
    }

    my $state = clients_state_for_server($server, 1);
    # A previous timeout makes numeric ownership ambiguous. Let native Irssi
    # handle manual /MAP unchanged until event 017 restores a clean boundary.
    return if $state && $state->{map_desynced};

    my $raw = 'MAP';
    $raw .= ' ' . $data if $data ne '';
    my $queued = clients_queue_or_start_manual_map($server, $raw);
    Irssi::signal_stop() if $queued;
}

sub clients_event_map_start {
    my ($server, $data) = @_;
    my $state = clients_state_for_server($server, 0);
    return unless $state;
    return if $state->{map_desynced};

    # Print a manually entered /MAP ourselves and stop the numeric so output is
    # deterministic on every Irssi build. Automatic snapshots remain hidden.
    if ($state->{manual_map_active}) {
        clients_print_manual_map_line($server, $data);
        Irssi::signal_stop();
        return;
    }
    return unless $state->{map_active};

    $state->{map_sum}     = 0;
    $state->{map_lines}   = 0;
    $state->{map_current} = {};

    Irssi::signal_stop();
}

sub clients_event_map_line {
    my ($server, $data) = @_;
    my $state = clients_state_for_server($server, 0);
    return unless $state;
    return if $state->{map_desynced};

    # Manual output is printed explicitly into Status 1. Only the script's own
    # snapshot is parsed for counters and suppressed without printing.
    if ($state->{manual_map_active}) {
        clients_print_manual_map_line($server, $data);
        Irssi::signal_stop();
        return;
    }
    return unless $state->{map_active};

    my $line = clients_map_payload($data);
    if ($line =~ /(?:^|\s)(\d+)\s+(\S+)\s+(\S+)\s*$/) {
        my ($users, $sid) = ($1, $2);
        $state->{map_current}{$sid} = int($users);
        $state->{map_sum} += int($users);
        $state->{map_lines}++;
    }

    Irssi::signal_stop();
}

sub clients_calculate_map_activity {
    my ($state) = @_;
    return unless $state && $state->{have_previous_map};

    my $map_in  = 0;
    my $map_out = 0;
    my $current = $state->{map_current};
    my $previous = $state->{map_previous};

    for my $sid (keys %$current) {
        next unless exists $previous->{$sid};
        my $delta = $current->{$sid} - $previous->{$sid};

        if ($delta > 0) {
            $map_in += $delta;
        }
        elsif ($delta < 0) {
            $map_out += -$delta;
        }
    }

    $state->{connections} += $map_in;
    $state->{quits}       += $map_out;
}

sub clients_event_map_end {
    my ($server, $data) = @_;
    my $state = clients_state_for_server($server, 0);
    return unless $state;

    if ($state->{map_desynced}) {
        # The first observed end-of-MAP after a timeout is a synchronization
        # boundary only. Leave it visible/native and resume tracking afterwards.
        $state->{map_desynced} = 0;
        return;
    }

    if ($state->{manual_map_active}) {
        clients_print_manual_map_line($server, $data);

        if ($state->{manual_map_timeout_tag}) {
            Irssi::timeout_remove($state->{manual_map_timeout_tag});
            $state->{manual_map_timeout_tag} = 0;
        }
        $state->{manual_map_active} = 0;

        # We printed the complete manual result ourselves, so suppress the raw
        # numeric to avoid duplicate lines on builds which would also render it.
        Irssi::signal_stop();
        clients_schedule_pending_manual_map($state);
        return;
    }

    return unless $state->{map_active};

    if ($state->{map_timeout_tag}) {
        Irssi::timeout_remove($state->{map_timeout_tag});
        $state->{map_timeout_tag} = 0;
    }

    if ($state->{map_lines} > 0) {
        $state->{users_total} = $state->{map_sum};
        $state->{map_failures} = 0;
        $state->{map_backoff_until} = 0;
        clients_calculate_map_activity($state);

        $state->{map_previous} = { %{ $state->{map_current} } };
        $state->{have_previous_map} = 1;
        clients_redraw_statusbar();
    }

    clients_clear_active_map($state);
    Irssi::signal_stop();

    # If the user requested /MAP while this background snapshot was running,
    # show it now that the hidden request has completed.
    clients_schedule_pending_manual_map($state);
}

sub clients_request_map_for_server {
    my ($server) = @_;
    return unless clients_server_is_mappable($server);

    my $state = clients_state_for_server($server, 1);
    return unless $state;
    return if $state->{map_desynced};
    return if ($state->{map_backoff_until} || 0) > time();
    return if $state->{map_active};
    return if $state->{manual_map_active};
    return if length($state->{manual_map_pending_raw} // '');

    $state->{map_active}  = 1;
    $state->{map_sum}     = 0;
    $state->{map_lines}   = 0;
    $state->{map_current} = {};

    if ($state->{map_timeout_tag}) {
        Irssi::timeout_remove($state->{map_timeout_tag});
    }

    $state->{map_timeout_tag} = Irssi::timeout_add_once(
        $MAP_TIMEOUT,
        'clients_map_timeout',
        $state->{tag},
    );

    $server->send_raw('MAP * s');
}

sub clients_request_all_maps {
    for my $server (Irssi::servers()) {
        clients_request_map_for_server($server);
    }
}

sub clients_periodic_map {
    return unless Irssi::settings_get_bool('clients_map_background_enabled');
    clients_request_all_maps();
}

sub clients_delayed_setup {
    my ($server_tag) = @_;
    delete $clients_setup_timer_tag{$server_tag};
    return if $matrix_unloading;
    return unless Irssi::settings_get_bool('clients_map_background_enabled');
    my $server = Irssi::server_find_tag($server_tag);
    clients_request_map_for_server($server) if $server;
}

sub clients_schedule_setup {
    return if $matrix_unloading;
    my ($server) = @_;
    return unless Irssi::settings_get_bool('clients_map_background_enabled');
    return unless clients_server_is_mappable($server);

    my $tag = clients_server_tag($server);
    return if $tag eq '';
    clients_state_for_server($server, 1);
    Irssi::timeout_remove($clients_setup_timer_tag{$tag})
        if $clients_setup_timer_tag{$tag};
    $clients_setup_timer_tag{$tag} = Irssi::timeout_add_once(
        3000,
        'clients_delayed_setup',
        $tag,
    );
}

sub clients_server_connected {
    my ($server) = @_;
    clients_schedule_setup($server);
}

sub clients_server_disconnected {
    my ($server) = @_;
    my $tag = clients_server_tag($server);
    return if $tag eq '' || !exists $clients_state{$tag};

    my $state = $clients_state{$tag};
    Irssi::timeout_remove($state->{map_timeout_tag})
        if $state->{map_timeout_tag};
    Irssi::timeout_remove($state->{manual_map_timeout_tag})
        if $state->{manual_map_timeout_tag};
    Irssi::timeout_remove($state->{pending_manual_timer_tag})
        if $state->{pending_manual_timer_tag};
    Irssi::timeout_remove($clients_setup_timer_tag{$tag})
        if $clients_setup_timer_tag{$tag};
    delete $clients_setup_timer_tag{$tag};
    delete $clients_state{$tag};
    clients_redraw_statusbar();
}

sub clients_active_context_changed {
    clients_redraw_statusbar();
    return unless Irssi::settings_get_bool('clients_map_background_enabled');

    # If this network has no snapshot yet, request it immediately on first view.
    my $server = clients_active_server();
    my $state = clients_state_for_server($server, 0);
    if ($server && (!$state || $state->{users_total} < 0)) {
        clients_request_map_for_server($server);
    }
}

sub clients_reset_state {
    my ($state) = @_;
    return unless $state;

    $state->{connections}       = 0;
    $state->{quits}             = 0;
    $state->{have_previous_map} = 0;
    $state->{map_previous}      = {};
}

sub clients_cmd_clientsreset {
    my ($data) = @_;
    $data = '' unless defined $data;
    $data =~ s/^\s+|\s+$//g;

    if (lc($data) eq 'all') {
        clients_reset_state($_) for values %clients_state;
        clients_request_all_maps();
        Irssi::print(
            'clients_activity: reset counters for all connected networks',
            Irssi::MSGLEVEL_CLIENTCRAP()
        );
    }
    else {
        my $server = clients_active_server();
        my $state = clients_state_for_server($server, 1);
        clients_reset_state($state);
        clients_request_map_for_server($server) if $server;

        my $network = $state ? ($state->{network} || $state->{tag}) : 'active network';
        Irssi::print(
            "clients_activity: reset counters for $network",
            Irssi::MSGLEVEL_CLIENTCRAP()
        );
    }

    clients_redraw_statusbar();
}

sub clients_cmd_clientsmap {
    my ($data) = @_;
    $data = '' unless defined $data;
    $data =~ s/^\s+|\s+$//g;

    if (lc($data) eq 'all') {
        clients_request_all_maps();
    }
    else {
        clients_request_map_for_server(clients_active_server());
    }
}

Irssi::signal_add_first('event 018', 'clients_event_map_start');
Irssi::signal_add_first('event 015', 'clients_event_map_line');
Irssi::signal_add_first('event 017', 'clients_event_map_end');

# Observe interactive /MAP before Irssi's native handler. Do not add another
# normal command binding: the built-in MAP handler is already registered and a
# second normal binding would send the request twice. Automatic snapshots use
# send_raw() and therefore never enter this command signal.
Irssi::signal_add_first('command map', 'clients_cmd_manual_map');

Irssi::signal_add('server connected',    'clients_server_connected');
Irssi::signal_add('server disconnected', 'clients_server_disconnected');
Irssi::signal_add_last('setup changed',  'clients_restart_periodic_timer');

for my $signal (
    'window changed',
    'window changed automatic',
    'window item changed',
    'window item server changed',
) {
    Irssi::signal_add_last($signal, 'clients_active_context_changed');
}

Irssi::command_bind('clientsreset', 'clients_cmd_clientsreset');
Irssi::command_bind('clientsmap',   'clients_cmd_clientsmap');

clients_configure_statusbar();
clients_restart_periodic_timer();

for my $server (Irssi::servers()) {
    clients_schedule_setup($server);
}
clients_redraw_statusbar();

sub clients_module_unload {
    for my $binding (
        [ 'event 018',                'clients_event_map_start' ],
        [ 'event 015',                'clients_event_map_line' ],
        [ 'event 017',                'clients_event_map_end' ],
        [ 'command map',              'clients_cmd_manual_map' ],
        [ 'server connected',         'clients_server_connected' ],
        [ 'server disconnected',      'clients_server_disconnected' ],
        [ 'setup changed',            'clients_restart_periodic_timer' ],
        [ 'window changed',           'clients_active_context_changed' ],
        [ 'window changed automatic', 'clients_active_context_changed' ],
        [ 'window item changed',      'clients_active_context_changed' ],
        [ 'window item server changed','clients_active_context_changed' ],
    ) {
        Irssi::signal_remove($binding->[0], $binding->[1]);
    }
    Irssi::command_unbind('clientsreset', 'clients_cmd_clientsreset');
    Irssi::command_unbind('clientsmap',   'clients_cmd_clientsmap');

    Irssi::timeout_remove($periodic_timer_tag) if $periodic_timer_tag;
    undef $periodic_timer_tag;

    for my $timer (values %clients_setup_timer_tag) {
        Irssi::timeout_remove($timer) if $timer;
    }
    %clients_setup_timer_tag = ();

    for my $state (values %clients_state) {
        Irssi::timeout_remove($state->{map_timeout_tag})
            if $state->{map_timeout_tag};
        Irssi::timeout_remove($state->{manual_map_timeout_tag})
            if $state->{manual_map_timeout_tag};
        Irssi::timeout_remove($state->{pending_manual_timer_tag})
            if $state->{pending_manual_timer_tag};
    }

    Irssi::command('^redraw');
}

1;
}

# =============================================================================
# Embedded nicklist.pl component
# Original concept/code: Wouter Coekaerts, GPLv2, nicklist 0.4.6.
# Integrated and hardened for the single-file matrix script. The component keeps
# SCREEN/FIFO output, the user's monochrome nick colours and /NICKLIST commands.
# =============================================================================
{
my ($NICKLIST_OFF, $NICKLIST_SCREEN, $NICKLIST_FIFO) = (0, 1, 2);
my ($NICKLIST_MODE_OP, $NICKLIST_MODE_HALFOP,
    $NICKLIST_MODE_VOICE, $NICKLIST_MODE_NORMAL) = (0, 1, 2, 3);

my $nicklist_mode = $NICKLIST_OFF;
my $nicklist_prev_lines = 0;
my $nicklist_scroll_pos = 0;
my $nicklist_channel_key = ''; # data only; no retained buffer/line references
my $nicklist_cursor_line = 0;
my $nicklist_need_redraw = 0;
my $nicklist_screen_resizing = 0;
my $nicklist_active_channel;
my @nicklist_entries;

my $nicklist_screen_prefix = '';
my @nicklist_mode_prefixes = (
    "\033[1;37m@\033[m",
    "\033[90m%\033[m",
    "\033[90m+\033[m",
    "\033[90m \033[m",
);
my $nicklist_irssi_width = 0;
my $nicklist_height = 24;
my $nicklist_width = 11;
my $nicklist_fifo_fh;

my $nicklist_redraw_timer;
my $nicklist_rebuild_timer;
my $nicklist_resize_timer;
my $nicklist_resize_done_timer;

sub nicklist_safe_int {
    my ($value, $default, $minimum, $maximum) = @_;
    $value = $default unless defined($value) && $value =~ /^-?\d+$/;
    $value = int($value);
    $value = $minimum if defined($minimum) && $value < $minimum;
    $value = $maximum if defined($maximum) && $value > $maximum;
    return $value;
}

sub nicklist_read_ansi_prefix {
    my ($setting, $fallback) = @_;
    my $value = Irssi::settings_get_str($setting);
    $value = $fallback unless defined $value;
    $value = matrix_decode_valid_utf8($value);
    $value =~ s/\\e/\033/g;

    # Prefix settings may contain SGR colour sequences, but never terminal
    # control strings such as OSC titles, cursor moves or line breaks.
    $value =~ s/\033(?!\[[0-9;]*m)//g;
    $value =~ s/[\x00-\x1a\x1c-\x1f\x7f-\x9f]//g;
    return length($value) ? $value : $fallback;
}

sub nicklist_prefix_plain_text {
    my ($prefix) = @_;
    $prefix = '' unless defined $prefix;
    $prefix =~ s/\033\[[0-9;]*m//g;
    return matrix_terminal_plain_text($prefix);
}

sub nicklist_read_settings {
    my $old_width = $nicklist_width;

    $nicklist_screen_prefix =
        Irssi::settings_get_str('nicklist_screen_prefix') // '';
    $nicklist_screen_prefix = matrix_decode_valid_utf8($nicklist_screen_prefix);
    $nicklist_screen_prefix =~ s/\\e/\033/g;
    # SCREEN prefix follows the same terminal-safety rule as mode prefixes:
    # printable text and SGR colour sequences only. Empty remains valid.
    $nicklist_screen_prefix =~ s/\033(?!\[[0-9;]*m)//g;
    $nicklist_screen_prefix =~ s/[\x00-\x1a\x1c-\x1f\x7f-\x9f]//g;

    @nicklist_mode_prefixes = (
        nicklist_read_ansi_prefix(
            'nicklist_prefix_mode_op', "\033[1;37m@\033[m"
        ),
        nicklist_read_ansi_prefix(
            'nicklist_prefix_mode_halfop', "\033[90m%\033[m"
        ),
        nicklist_read_ansi_prefix(
            'nicklist_prefix_mode_voice', "\033[90m+\033[m"
        ),
        nicklist_read_ansi_prefix(
            'nicklist_prefix_mode_normal', "\033[90m \033[m"
        ),
    );

    $nicklist_width = nicklist_safe_int(
        Irssi::settings_get_int('nicklist_width'), 11, 4, 80,
    );

    if ($nicklist_mode != $NICKLIST_SCREEN) {
        $nicklist_height = nicklist_safe_int(
            Irssi::settings_get_int('nicklist_height'), 24, 1, 1000,
        );
    }

    if ($nicklist_mode == $NICKLIST_SCREEN && $old_width != $nicklist_width) {
        nicklist_schedule_screen_resize();
    }
}

sub nicklist_channel_is_usable {
    my ($channel) = @_;
    return 0 unless $channel;
    return 0 unless uc($channel->{type} // '') eq 'CHANNEL';
    return 1 if uc($channel->{chat_type} // '') eq 'SILC';
    return $channel->{names_got} ? 1 : 0;
}

sub nicklist_current_channel {
    my $window = Irssi::active_win();
    return undef unless $window;
    my $channel = $window->{active};
    return nicklist_channel_is_usable($channel) ? $channel : undef;
}

sub nicklist_channel_matches {
    my ($server, $channel_name) = @_;
    return 0 unless $nicklist_active_channel && $server;
    return 0 unless defined($channel_name) && length($channel_name);

    my $active_server = $nicklist_active_channel->{server};
    return 0 unless $active_server;
    return 0 unless lc($active_server->{tag} // '') eq lc($server->{tag} // '');
    return matrix_irc_casefold($server, $nicklist_active_channel->{name}) eq matrix_irc_casefold($server, $channel_name) ? 1 : 0;
}

sub nicklist_mode_for_nick {
    my ($nick) = @_;
    return $NICKLIST_MODE_NORMAL unless $nick;
    return $NICKLIST_MODE_OP     if $nick->{op};
    return $NICKLIST_MODE_HALFOP if $nick->{halfop};
    return $NICKLIST_MODE_VOICE  if $nick->{voice};
    return $NICKLIST_MODE_NORMAL;
}

sub nicklist_calc_entry_text {
    my ($entry) = @_;
    return unless $entry;

    my $raw_nick = matrix_terminal_plain_text($entry->{nick} // '');
    my $mode = int($entry->{mode} // $NICKLIST_MODE_NORMAL);
    $mode = $NICKLIST_MODE_NORMAL
        if $mode < $NICKLIST_MODE_OP || $mode > $NICKLIST_MODE_NORMAL;
    my $prefix = $nicklist_mode_prefixes[$mode];
    my $prefix_cells = matrix_text_width(nicklist_prefix_plain_text($prefix));
    my $nick_cells = $nicklist_width - $prefix_cells;
    $nick_cells = 1 if $nick_cells < 1;
    my $shown = $raw_nick;
    if (matrix_text_width($shown) > $nick_cells) {
        $shown = $nick_cells == 1
            ? '~'
            : matrix_text_truncate_cells($shown, $nick_cells - 1) . '~';
    }
    my $padding = ' ' x ($nick_cells - matrix_text_width($shown));

    # ANSI equivalents of Irssi colours:
    # %K = bright black / dark grey, %W = bold white, %w = white.
    my $RESET = "\033[m";
    my $K     = "\033[90m";
    my $W     = "\033[1;37m";
    my $w     = "\033[37m";

    my $nick_colour;
    if ($mode == $NICKLIST_MODE_OP) {
        $nick_colour = $W;
    }
    elsif ($mode == $NICKLIST_MODE_VOICE) {
        $nick_colour = $w;
    }
    elsif ($mode == $NICKLIST_MODE_HALFOP) {
        $nick_colour = $K;
    }
    else {
        $nick_colour = $K;
    }

    $entry->{text} = $prefix . $nick_colour . $shown . $RESET . $padding;
    $entry->{cmp} = sprintf('%d%s', $mode, lc($raw_nick));
}

sub nicklist_make {
    return if $matrix_unloading || $nicklist_mode == $NICKLIST_OFF;
    @nicklist_entries = ();

    my $channel = nicklist_current_channel();
    my $key = $channel ? netbar_channel_key($channel->{server}, $channel->{name}) : '';
    $nicklist_scroll_pos = 0 if $key ne $nicklist_channel_key;
    $nicklist_channel_key = $key;
    if (!$channel) {
        $nicklist_active_channel = undef;
        nicklist_need_redraw();
        return;
    }

    $nicklist_active_channel = $channel;
    my @nicks = eval { $channel->nicks() };
    @nicks = () if $@;

    @nicks = sort {
        my $a_mode = nicklist_mode_for_nick($a);
        my $b_mode = nicklist_mode_for_nick($b);
        $a_mode <=> $b_mode
            || lc($a->{nick} // '') cmp lc($b->{nick} // '')
    } @nicks;

    for my $nick (@nicks) {
        next unless $nick && defined($nick->{nick});
        my $entry = {
            nick => $nick->{nick},
            mode => nicklist_mode_for_nick($nick),
        };
        nicklist_calc_entry_text($entry);
        push @nicklist_entries, $entry;
    }

    nicklist_need_redraw();
}

sub nicklist_run_rebuild {
    undef $nicklist_rebuild_timer;
    nicklist_read_settings();
    nicklist_make();
}

sub nicklist_schedule_rebuild {
    return if $matrix_unloading || $nicklist_mode == $NICKLIST_OFF;
    Irssi::timeout_remove($nicklist_rebuild_timer)
        if defined $nicklist_rebuild_timer;
    $nicklist_rebuild_timer = Irssi::timeout_add_once(
        25, 'nicklist_run_rebuild', 0,
    );
}

sub nicklist_write_start {
    print STDERR "\033P\033[s\033\\"
        if $nicklist_mode == $NICKLIST_SCREEN;
}

sub nicklist_write_end {
    print STDERR "\033P\033[u\033\\"
        if $nicklist_mode == $NICKLIST_SCREEN;
}

sub nicklist_fifo_write {
    my ($data) = @_;
    return 0 unless $nicklist_fifo_fh;
    local $SIG{PIPE} = 'IGNORE';

    my $written = print {$nicklist_fifo_fh} $data;
    return $written ? 1 : 0;
}

sub nicklist_write_line {
    my ($line, $data) = @_;
    $line = int($line // 0);
    $data = '' unless defined $data;

    if ($nicklist_mode == $NICKLIST_SCREEN) {
        print STDERR "\033P\033[" . ($line + 1) . ';'
            . ($nicklist_irssi_width + 1) . 'H'
            . $nicklist_screen_prefix . $data . "\033\\";
        return;
    }

    return unless $nicklist_mode == $NICKLIST_FIFO;

    $data = "\033[m$data";
    if ($line == $nicklist_cursor_line + 1) {
        $data = "\n$data";
    }
    elsif ($line == $nicklist_cursor_line) {
        $data = "\033[1G$data";
    }
    else {
        $data = "\033[" . ($line + 1) . ";0H$data";
    }
    $nicklist_cursor_line = $line;

    nicklist_fifo_stop(0) unless nicklist_fifo_write($data);
}

sub nicklist_redraw {
    undef $nicklist_redraw_timer;
    $nicklist_need_redraw = 0;
    return if $nicklist_mode == $NICKLIST_OFF;

    my $max_scroll = @nicklist_entries > $nicklist_height
        ? scalar(@nicklist_entries) - $nicklist_height
        : 0;
    $nicklist_scroll_pos = $max_scroll
        if $nicklist_scroll_pos > $max_scroll;
    $nicklist_scroll_pos = 0 if $nicklist_scroll_pos < 0;

    nicklist_write_start();
    my $line = 0;
    for (my $i = $nicklist_scroll_pos;
         $line < $nicklist_height && $i < @nicklist_entries;
         $i++) {
        nicklist_write_line($line++, $nicklist_entries[$i]->{text});
    }

    my $real_lines = $line;
    while ($line < $nicklist_prev_lines) {
        nicklist_write_line($line++, ' ' x $nicklist_width);
    }
    $nicklist_prev_lines = $real_lines;
    nicklist_write_end();
}

sub nicklist_need_redraw {
    return if $nicklist_mode == $NICKLIST_OFF;
    $nicklist_need_redraw = 1;
    return if defined $nicklist_redraw_timer;
    $nicklist_redraw_timer = Irssi::timeout_add_once(
        10, 'nicklist_redraw', 0,
    );
}

sub nicklist_terminal_size {
    my ($columns, $rows);

    eval {
        require Term::ReadKey;
        ($columns, $rows) = Term::ReadKey::GetTerminalSize(*STDOUT);
    };

    if (!$columns || !$rows) {
        my $winsize = '';
        if (open my $tty, '+<', '/dev/tty') {
            if (ioctl($tty, 0x5413, $winsize)) {
                my ($r, $c) = unpack('S4', $winsize);
                ($columns, $rows) = ($c, $r) if $c && $r;
            }
            close $tty;
        }
    }

    $columns ||= nicklist_safe_int($ENV{COLUMNS}, 80, 20, 10000);
    $rows    ||= nicklist_safe_int($ENV{LINES}, 24, 2, 10000);
    return (int($columns), int($rows));
}


sub nicklist_screen_binary {
    for my $candidate ('/usr/bin/screen', '/bin/screen') {
        return $candidate if -x $candidate && -f $candidate;
    }
    return '';
}

sub nicklist_screen_command {
    my (@args) = @_;
    my $sty = $ENV{STY};
    return 0 unless defined($sty) && length($sty);
    return 0 unless $sty =~ /\A[A-Za-z0-9_.:\/-]+\z/;
    return 0 if $sty =~ /\A-/;

    my $screen = nicklist_screen_binary();
    return 0 unless length $screen;
    return system($screen, '-x', $sty, '-X', @args) == 0 ? 1 : 0;
}

sub nicklist_screen_resize_done {
    undef $nicklist_resize_done_timer;
    $nicklist_screen_resizing = 0;
    nicklist_need_redraw();
    netbar_force_rebuild() if defined &netbar_force_rebuild;
}

sub nicklist_screen_size {
    undef $nicklist_resize_timer;
    return 0 unless $nicklist_mode == $NICKLIST_SCREEN;

    if (defined $nicklist_resize_done_timer) {
        Irssi::timeout_remove($nicklist_resize_done_timer);
        undef $nicklist_resize_done_timer;
    }
    $nicklist_screen_resizing = 1;
    unless (nicklist_screen_command('fit')) {
        $nicklist_screen_resizing = 0;
        return 0;
    }

    my ($columns, $rows) = nicklist_terminal_size();
    $nicklist_irssi_width = $columns - $nicklist_width - 1;
    $nicklist_irssi_width = 20 if $nicklist_irssi_width < 20;
    $nicklist_height = $rows - 1;
    $nicklist_height = 1 if $nicklist_height < 1;

    unless (nicklist_screen_command('width', '-w', $nicklist_irssi_width)) {
        # Best effort: return the Screen display to its fitted width if the
        # requested nicklist split could not be applied.
        nicklist_screen_command('fit');
        $nicklist_screen_resizing = 0;
        return 0;
    }

    Irssi::timeout_remove($nicklist_resize_done_timer)
        if defined $nicklist_resize_done_timer;
    $nicklist_resize_done_timer = Irssi::timeout_add_once(
        1000, 'nicklist_screen_resize_done', 0,
    );
    return 1;
}

sub nicklist_screen_resize_tick {
    return unless $nicklist_mode == $NICKLIST_SCREEN;
    return if nicklist_screen_size();

    # A later Screen failure must not leave the UI claiming SCREEN mode is
    # active with stale geometry. Drop cleanly to OFF; a subsequent
    # /nicklist screen can retry from a fresh state.
    nicklist_screen_stop(0);
    Irssi::print(
        'nicklist: GNU Screen resize failed; SCREEN mode was disabled.',
        Irssi::MSGLEVEL_CLIENTERROR(),
    );
}

sub nicklist_schedule_screen_resize {
    return unless $nicklist_mode == $NICKLIST_SCREEN;
    Irssi::timeout_remove($nicklist_resize_timer)
        if defined $nicklist_resize_timer;
    $nicklist_resize_timer = Irssi::timeout_add_once(
        100, 'nicklist_screen_resize_tick', 0,
    );
}

sub nicklist_signal_terminal_resized {
    return if $nicklist_screen_resizing;
    nicklist_schedule_screen_resize();
}

sub nicklist_signal_page_scrolled {
    $nicklist_prev_lines = $nicklist_height;
    nicklist_need_redraw();
}

sub nicklist_signal_gui_print_finished {
    return if $nicklist_need_redraw;

    my ($window) = @_;
    my $active = Irssi::active_win();
    if ($window && $active && defined($window->{refnum})
        && defined($active->{refnum})
        && int($window->{refnum}) == int($active->{refnum})) {
        nicklist_need_redraw();
        return;
    }

    my $wanted = Irssi::settings_get_str('nicklist_screen_split_windows') // '';
    return if $wanted eq '';
    if ($wanted eq '*') {
        nicklist_need_redraw();
        return;
    }

    for my $candidate (grep { length } split /[ ,]+/, $wanted) {
        next unless $window;
        if ((defined($window->{refnum}) && "$window->{refnum}" eq $candidate)
            || (defined($window->{name}) && $window->{name} eq $candidate)) {
            nicklist_need_redraw();
            return;
        }
    }
}

sub nicklist_screen_start {
    if (!defined($ENV{STY}) || $ENV{STY} eq '') {
        Irssi::print(
            'nicklist: SCREEN mode requires GNU Screen.',
            Irssi::MSGLEVEL_CLIENTERROR(),
        );
        return;
    }
    return if $nicklist_mode == $NICKLIST_SCREEN;

    nicklist_fifo_stop(0) if $nicklist_mode == $NICKLIST_FIFO;
    nicklist_read_settings();
    $nicklist_mode = $NICKLIST_SCREEN;
    $nicklist_prev_lines = 0;

    unless (nicklist_screen_size()) {
        $nicklist_mode = $NICKLIST_OFF;
        $nicklist_screen_resizing = 0;
        nicklist_discard_cached_list();
        Irssi::print(
            'nicklist: failed to configure GNU Screen; SCREEN mode was not enabled.',
            Irssi::MSGLEVEL_CLIENTERROR(),
        );
        return;
    }

    Irssi::signal_add_last(
        'gui print text finished', 'nicklist_signal_gui_print_finished',
    );
    Irssi::signal_add_last('gui page scrolled', 'nicklist_signal_page_scrolled');
    Irssi::signal_add('terminal resized', 'nicklist_signal_terminal_resized');

    nicklist_make();
    Irssi::print('nicklist: SCREEN mode enabled.');
}

sub nicklist_screen_stop {
    my ($announce) = @_;
    return unless $nicklist_mode == $NICKLIST_SCREEN;

    if (defined $nicklist_resize_timer) {
        Irssi::timeout_remove($nicklist_resize_timer);
        undef $nicklist_resize_timer;
    }
    if (defined $nicklist_resize_done_timer) {
        Irssi::timeout_remove($nicklist_resize_done_timer);
        undef $nicklist_resize_done_timer;
    }
    $nicklist_screen_resizing = 0;
    $nicklist_mode = $NICKLIST_OFF;
    Irssi::signal_remove(
        'gui print text finished', 'nicklist_signal_gui_print_finished',
    );
    Irssi::signal_remove('gui page scrolled', 'nicklist_signal_page_scrolled');
    Irssi::signal_remove('terminal resized', 'nicklist_signal_terminal_resized');

    nicklist_screen_command('fit');
    $nicklist_prev_lines = 0;
    netbar_force_rebuild() if defined &netbar_force_rebuild;
    nicklist_discard_cached_list();
    Irssi::print('nicklist: SCREEN mode disabled.') if $announce;
}

sub nicklist_fifo_start {
    nicklist_read_settings();
    my $path = Irssi::settings_get_str('nicklist_fifo_path') // '';
    if ($path eq '') {
        Irssi::print('nicklist: nicklist_fifo_path is empty.', Irssi::MSGLEVEL_CLIENTERROR());
        return;
    }

    my @lst = lstat($path);
    if (!@lst) {
        require POSIX;
        if (!POSIX::mkfifo($path, 0600)) {
            Irssi::print(
                "nicklist: cannot create FIFO $path: $!",
                Irssi::MSGLEVEL_CLIENTERROR(),
            );
            return;
        }
        chmod 0600, $path;
        Irssi::print(
            "nicklist: private FIFO created. Run in another terminal: cat $path",
        );
        return;
    }

    if (-l _ || !-p _) {
        Irssi::print(
            "nicklist: $path is not a direct FIFO (symlinks are rejected).",
            Irssi::MSGLEVEL_CLIENTERROR(),
        );
        return;
    }
    if ($lst[4] != $<) {
        Irssi::print(
            "nicklist: FIFO $path is not owned by the current user.",
            Irssi::MSGLEVEL_CLIENTERROR(),
        );
        return;
    }

    chmod 0600, $path or do {
        Irssi::print(
            "nicklist: cannot set mode 0600 on $path: $!",
            Irssi::MSGLEVEL_CLIENTERROR(),
        );
        return;
    };

    if (!$MATRIX_O_NOFOLLOW) {
        Irssi::print(
            'nicklist: O_NOFOLLOW is unavailable; FIFO mode was refused for safety.',
            Irssi::MSGLEVEL_CLIENTERROR(),
        );
        return;
    }

    my $fh;
    my $flags = O_WRONLY | O_NONBLOCK | $MATRIX_O_NOFOLLOW;
    if (!sysopen($fh, $path, $flags)) {
        Irssi::print(
            "nicklist: cannot safely open FIFO $path: $!. Run: cat $path",
            Irssi::MSGLEVEL_CLIENTERROR(),
        );
        return;
    }

    my @opened = stat($fh);
    if (!@opened || !-p $fh || $opened[4] != $< || ($opened[2] & 0077)) {
        close $fh;
        Irssi::print(
            "nicklist: opened FIFO validation failed for $path.",
            Irssi::MSGLEVEL_CLIENTERROR(),
        );
        return;
    }
    $fh->autoflush(1);

    nicklist_screen_stop(0) if $nicklist_mode == $NICKLIST_SCREEN;
    nicklist_fifo_stop(0) if $nicklist_mode == $NICKLIST_FIFO;

    $nicklist_fifo_fh = $fh;
    $nicklist_mode = $NICKLIST_FIFO;
    $nicklist_cursor_line = 0;
    $nicklist_prev_lines = 0;
    nicklist_fifo_write("\033[2J\033[1;1H");
    nicklist_make();
    Irssi::print("nicklist: FIFO mode enabled ($path, 0600).");
}

sub nicklist_fifo_stop {
    my ($announce) = @_;
    return unless $nicklist_mode == $NICKLIST_FIFO || $nicklist_fifo_fh;

    if ($nicklist_fifo_fh) {
        close $nicklist_fifo_fh;
        undef $nicklist_fifo_fh;
    }
    $nicklist_mode = $NICKLIST_OFF
        if $nicklist_mode == $NICKLIST_FIFO;
    $nicklist_prev_lines = 0;
    nicklist_discard_cached_list();
    Irssi::print('nicklist: FIFO closed.') if $announce;
}

sub nicklist_discard_cached_list {
    Irssi::timeout_remove($nicklist_rebuild_timer)
        if defined $nicklist_rebuild_timer;
    undef $nicklist_rebuild_timer;
    @nicklist_entries = ();
    undef $nicklist_active_channel;
    $nicklist_channel_key = '';
    $nicklist_scroll_pos = 0;
}

sub nicklist_off {
    if ($nicklist_mode == $NICKLIST_SCREEN) {
        nicklist_screen_stop(1);
    }
    elsif ($nicklist_mode == $NICKLIST_FIFO) {
        nicklist_fifo_stop(1);
    }
}

sub nicklist_scroll {
    my ($amount) = @_;
    return unless $nicklist_active_channel;
    $amount = nicklist_safe_int($amount, 0, -100000, 100000);

    my $max_scroll = @nicklist_entries > $nicklist_height
        ? scalar(@nicklist_entries) - $nicklist_height
        : 0;
    $nicklist_scroll_pos += $amount;
    $nicklist_scroll_pos = 0 if $nicklist_scroll_pos < 0;
    $nicklist_scroll_pos = $max_scroll
        if $nicklist_scroll_pos > $max_scroll;
    nicklist_need_redraw();
}

sub nicklist_command_help {
    my $level = Irssi::MSGLEVEL_CLIENTCRAP();
    Irssi::print('%WNICKLIST SCREEN%w - nick list on the right of GNU Screen.', $level);
    Irssi::print('%WNICKLIST FIFO%w - nick list in a separate terminal through FIFO.', $level);
    Irssi::print('%WNICKLIST OFF%w - disable the active mode.', $level);
    Irssi::print('%WNICKLIST UPDATE%w - refresh the list and settings.', $level);
    Irssi::print('%WNICKLIST SCROLL N%w - scroll the list by N entries.', $level);
    Irssi::print('%WNICKLIST SCREENSIZE%w - readjust the GNU Screen width.', $level);
}

sub nicklist_command_root {
    my ($data, $server, $item) = @_;
    $data = '' unless defined $data;
    $data =~ s/^\s+|\s+$//g;
    if ($data eq '') {
        nicklist_command_help();
        return;
    }
    Irssi::command_runsub('nicklist', $data, $server, $item);
}

sub nicklist_command_update {
    nicklist_read_settings();
    nicklist_make();
}

sub nicklist_command_scroll {
    my ($data) = @_;
    nicklist_scroll($data);
}

sub nicklist_command_screen {
    nicklist_screen_start();
}

sub nicklist_command_fifo {
    nicklist_fifo_start();
}

sub nicklist_command_off {
    nicklist_off();
}

sub nicklist_command_screensize {
    if ($nicklist_mode == $NICKLIST_SCREEN) {
        nicklist_screen_resize_tick();
    }
    else {
        Irssi::print('nicklist: SCREEN mode is not active.');
    }
}

sub nicklist_signal_unknown_command {
    nicklist_command_help();
}

sub nicklist_signal_window_changed {
    nicklist_schedule_rebuild();
}

sub nicklist_signal_channel_wholist {
    my ($channel) = @_;
    return unless $channel;
    my $current = nicklist_current_channel();
    return unless $current;
    return unless matrix_irc_casefold($channel->{server}, $current->{name}) eq matrix_irc_casefold($channel->{server}, $channel->{name});
    my $current_server = $current->{server};
    my $channel_server = $channel->{server};
    return unless $current_server && $channel_server;
    return unless lc($current_server->{tag} // '') eq lc($channel_server->{tag} // '');
    nicklist_schedule_rebuild();
}

sub nicklist_signal_join_part_kick {
    my ($server, $channel_name) = @_;
    nicklist_schedule_rebuild()
        if nicklist_channel_matches($server, $channel_name);
}

sub nicklist_signal_quit_nick {
    my ($server) = @_;
    return unless $nicklist_active_channel && $server;
    my $active_server = $nicklist_active_channel->{server};
    return unless $active_server;
    nicklist_schedule_rebuild()
        if lc($active_server->{tag} // '') eq lc($server->{tag} // '');
}

sub nicklist_signal_mode {
    my ($channel) = @_;
    return unless $channel && $channel->{server};
    nicklist_schedule_rebuild()
        if nicklist_channel_matches($channel->{server}, $channel->{name} // '');
}

sub nicklist_module_unload {
    for my $timer (
        $nicklist_redraw_timer,
        $nicklist_rebuild_timer,
        $nicklist_resize_timer,
        $nicklist_resize_done_timer,
    ) {
        Irssi::timeout_remove($timer) if defined $timer;
    }
    undef $nicklist_redraw_timer;
    undef $nicklist_rebuild_timer;
    undef $nicklist_resize_timer;
    undef $nicklist_resize_done_timer;

    nicklist_screen_stop(0) if $nicklist_mode == $NICKLIST_SCREEN;
    nicklist_fifo_stop(0) if $nicklist_mode == $NICKLIST_FIFO || $nicklist_fifo_fh;

    Irssi::signal_remove('window item changed', 'nicklist_signal_window_changed');
    Irssi::signal_remove('window changed', 'nicklist_signal_window_changed');
    Irssi::signal_remove('channel wholist', 'nicklist_signal_channel_wholist');
    Irssi::signal_remove('message join', 'nicklist_signal_join_part_kick');
    Irssi::signal_remove('message part', 'nicklist_signal_join_part_kick');
    Irssi::signal_remove('message kick', 'nicklist_signal_join_part_kick');
    Irssi::signal_remove('message quit', 'nicklist_signal_quit_nick');
    Irssi::signal_remove('message nick', 'nicklist_signal_quit_nick');
    Irssi::signal_remove('message own_nick', 'nicklist_signal_quit_nick');
    Irssi::signal_remove('nick mode changed', 'nicklist_signal_mode');
    Irssi::signal_remove('setup changed', 'nicklist_read_settings');
    Irssi::signal_remove(
        'default command nicklist',
        'nicklist_signal_unknown_command',
    );
    for my $binding (
        [ 'nicklist',            'nicklist_command_root' ],
        [ 'nicklist update',     'nicklist_command_update' ],
        [ 'nicklist help',       'nicklist_command_help' ],
        [ 'nicklist scroll',     'nicklist_command_scroll' ],
        [ 'nicklist fifo',       'nicklist_command_fifo' ],
        [ 'nicklist screen',     'nicklist_command_screen' ],
        [ 'nicklist screensize', 'nicklist_command_screensize' ],
        [ 'nicklist off',        'nicklist_command_off' ],
    ) {
        Irssi::command_unbind($binding->[0], $binding->[1]);
    }
}

Irssi::settings_add_str('nicklist', 'nicklist_screen_prefix', '\\e[m ');
Irssi::settings_add_str('nicklist', 'nicklist_prefix_mode_op', '\\e[1;37m@\\e[m');
Irssi::settings_add_str('nicklist', 'nicklist_prefix_mode_halfop', '\\e[90m%\\e[m');
Irssi::settings_add_str('nicklist', 'nicklist_prefix_mode_voice', '\\e[90m+\\e[m');
Irssi::settings_add_str('nicklist', 'nicklist_prefix_mode_normal', '\\e[90m \\e[m');
Irssi::settings_add_int('nicklist', 'nicklist_width', 11);
Irssi::settings_add_int('nicklist', 'nicklist_height', 24);
Irssi::settings_add_str(
    'nicklist', 'nicklist_fifo_path', Irssi::get_irssi_dir() . '/nicklistfifo',
);
Irssi::settings_add_str('nicklist', 'nicklist_screen_split_windows', '');
Irssi::settings_add_str('nicklist', 'nicklist_automode', '');

Irssi::command_bind('nicklist', 'nicklist_command_root');
Irssi::signal_add_first('default command nicklist', 'nicklist_signal_unknown_command');
Irssi::command_bind('nicklist update', 'nicklist_command_update');
Irssi::command_bind('nicklist help', 'nicklist_command_help');
Irssi::command_bind('nicklist scroll', 'nicklist_command_scroll');
Irssi::command_bind('nicklist fifo', 'nicklist_command_fifo');
Irssi::command_bind('nicklist screen', 'nicklist_command_screen');
Irssi::command_bind('nicklist screensize', 'nicklist_command_screensize');
Irssi::command_bind('nicklist off', 'nicklist_command_off');

Irssi::signal_add_last('window item changed', 'nicklist_signal_window_changed');
Irssi::signal_add_last('window changed', 'nicklist_signal_window_changed');
Irssi::signal_add_last('channel wholist', 'nicklist_signal_channel_wholist');
Irssi::signal_add_first('message join', 'nicklist_signal_join_part_kick');
Irssi::signal_add_first('message part', 'nicklist_signal_join_part_kick');
Irssi::signal_add_first('message kick', 'nicklist_signal_join_part_kick');
Irssi::signal_add_first('message quit', 'nicklist_signal_quit_nick');
Irssi::signal_add_first('message nick', 'nicklist_signal_quit_nick');
Irssi::signal_add_first('message own_nick', 'nicklist_signal_quit_nick');
Irssi::signal_add_first('nick mode changed', 'nicklist_signal_mode');
Irssi::signal_add('setup changed', 'nicklist_read_settings');

nicklist_read_settings();
nicklist_make();
my $nicklist_automode = uc(Irssi::settings_get_str('nicklist_automode') // '');
if ($nicklist_automode eq 'SCREEN') {
    nicklist_screen_start();
}
elsif ($nicklist_automode eq 'FIFO') {
    nicklist_fifo_start();
}

}


# Irssi can restore its theme/statusbar configuration after scripts from
# ~/.irssi/scripts/autorun have already loaded. Reapply the complete layout a
# few times during startup and once after the final JOIN burst. This removes the
# need for a manual unload/load cycle on a clean Irssi profile.
sub matrix_stabilize_layout {
    return if $matrix_unloading;
    matrix_apply_message_timestamp_format();
    matrix_apply_event_formats();
    matrix_apply_dynamic_prompt();
    netbar_install_navigation_bindings()
        if defined &netbar_install_navigation_bindings;

    # Recreate the structural bars first, then normalize their contents. The
    # previous order added a flexible filler before the channel item during some
    # startup races, which shifted each row by a different amount.
    netbar_apply_standard_bar_choice() if defined &netbar_apply_standard_bar_choice;
    netbar_force_rebuild() if defined &netbar_force_rebuild;

    # Reapply the black clock/context footer and blue topic bar after the
    # startup configuration has finished loading.
    netbar_sidebar_reapply_chrome();

    # Remove obsolete standalone items, then redraw the Network/NET heading.
    clients_configure_statusbar() if defined &clients_configure_statusbar;
    clients_redraw_statusbar() if defined &clients_redraw_statusbar;
    Irssi::command('^redraw');
}

sub matrix_run_scheduled_layout_settle {
    $matrix_layout_settle_tag = undef;
    return if $matrix_unloading;
    matrix_stabilize_layout();
}

sub matrix_schedule_layout_settle {
    return if $matrix_unloading;
    Irssi::timeout_remove($matrix_layout_settle_tag)
        if defined $matrix_layout_settle_tag;

    # Debounce channel creation/join signals. With many auto-joined channels,
    # the final rebuild runs only after the last one has settled.
    $matrix_layout_settle_tag = Irssi::timeout_add_once(
        1500,
        'matrix_run_scheduled_layout_settle',
        0,
    );
}

for my $signal ('server connected', 'channel created', 'channel joined') {
    Irssi::signal_add_last($signal, 'matrix_schedule_layout_settle');
}

matrix_stabilize_layout();
@matrix_startup_settle_tags = (
    Irssi::timeout_add_once(250,  'matrix_stabilize_layout', 0),
    Irssi::timeout_add_once(1500, 'matrix_stabilize_layout', 0),
    Irssi::timeout_add_once(5000, 'matrix_stabilize_layout', 0),
);
$matrix_dark_tick_tag = Irssi::timeout_add(1000, 'matrix_dark_tick', 0);
matrix_dark_tick();

sub matrix_print_help {
    my $level = Irssi::MSGLEVEL_CLIENTCRAP();
    Irssi::print('%R[%WMATRIX ' . $VERSION . '%R]%W loaded. Available commands:', $level);
    Irssi::print('%W/matrixhelp%w - show this help again.', $level);
    Irssi::print('%W/nickalign [left|right|default|status]%w - toggle public nickname alignment: RIGHT (9 cells) or LEFT (native Irssi, no padding).', $level);
    Irssi::print('%W/netbars sidebar|on|off|mouse [on|off|status]|refresh|rebuild|opers|clean|restore%w - manage the network, Status and channel sidebar.', $level);
    Irssi::print('%W/gaway [reason]|off%w - set AWAY on all connected networks; no argument uses Away. %W/away [reason]|off%w affects only the active network.', $level);
    Irssi::print('%W/autoaway on|off|status|duration 1-10080[min]%w - disabled by default. Set the idle timeout with 60min, 120min or 2h. OFF clears only automatic AWAY and preserves manual AWAY.', $level);
    Irssi::print('%W/mute [#channel] / /unmute [#channel]%w - mute or restore notifications on the current network; no argument uses the active channel. Automatic /SAVE is OFF by default; enable with /set network_statusbars_mute_autosave ON.', $level);
    Irssi::print('%WCtrl-X / /matrixnetnext%w - switch to the last active conversation on the next network; in Status 1, switch the selected network.', $level);
    Irssi::print('%WAlt+arrows%w - up/down navigate channels and networks. Left/right navigate all PMs across networks, following their sidebar order. Native Irssi and GNU Screen/SSH key sequences are supported. Alt+1 or /window 1 opens Status.', $level);
    Irssi::print('%WMouse%w - left-click opens a network, Status, channel or PM. Drag between channels on the same network to swap window numbers; cross-network swaps are blocked. /matrixundo reverses a swap. Repeat right-click within 1.5 s to close a channel or query. The wheel over PMs scrolls that network\'s PM list; over channels it navigates channels. Edge arrows scroll an overflowing sidebar. Double left-click (within 0.5 s) on the author nick of a public message or on a nick shown by /NAMES (/n) opens that person\'s PM on the same connection. Shift+mouse selects/pastes.', $level);
    Irssi::print('%WBlue bar%w - shows network / channel or network / nick. PM: N counts unread private conversations across all networks, once per network and nick. Reading a conversation clears one from the total. Mentions: N counts unread channel messages containing your nick. Zero counters are hidden. History shows older messages; Scroll down or use /scrollback end to return to Live. A New: N count appears when messages arrive while you read history.', $level);
    Irssi::print('%WMentions%w - opening a channel with unread mentions jumps to First unread mention. Read to the bottom to clear its unread counters. Incoming messages never change your active conversation. Ignored, muted and own messages do not create mention alerts.', $level);
    Irssi::print('%WLayout%w - the blue separator has one black spacer row on each side. The clock [HH:MM:SS] sits below it, followed by the input prompt ~$. The context and clock have matching left insets. The top bar shows the channel topic or the last known nick!ident@host of your PM correspondent.', $level);
    Irssi::print('%W/matrixmouse on|off|status|precise|offset N%w - mouse controls; SGR 1006 with VT200 fallback, paired press/release validation and offset=2 by default.', $level);
    Irssi::print('%W/matrix window|join|status%w - WINDOW animates channel switches; JOIN animates /j and /join. /matrix screen on|off|status permits transitions in GNU Screen (OFF by default); /matrix duration 180-900; /matrix cursor toggles BLOCK and UNDERSCORE; /matrix input on|off|test; /matrix screen input on|off|status permits typing effects in GNU Screen (OFF by default); /matrix input duration 20-120.', $level);
    Irssi::print('%W/nicklist screen|fifo|off|update|scroll N|screensize|help%w - show channel nicknames on the right of GNU Screen, or through FIFO in another terminal.', $level);
    Irssi::print('%W/clientsmap [all]%w - quietly refresh Network/NET for the active network or all networks.', $level);
    Irssi::print('%W/clientsreset [all]%w - reset the baseline and IN/OUT/NET counters for the active network or all networks.', $level);
    Irssi::print('%W/prvguard status|on|off|unlock|purge|ctcp off|limit|strict|block%w - control PM/CTCP flood protection. Automatic cleanup closes only new untrusted queries created by the flood. Existing and trusted conversations are preserved. PURGE closes all queries in batches.', $level);
    Irssi::print('%W/pmunread%w - list unread private conversations.', $level);
    Irssi::print('%W/pmclear [nick|all]%w - clear selected or all unread PM alerts and their ages without closing queries.', $level);
    Irssi::print('%W/pmtest [nick]%w - create a test unread PM alert.', $level);
    Irssi::print('%WPM list%w - below each network\'s channels, after a blank row and a PM heading. Each query has a clickable number/nick. Unread entries use steady magenta, without blinking; read entries dim and remain until closed. Reading at the bottom, replying with /MSG, /ME or /ACTION, or /pmclear clears the alert and age.', $level);
    Irssi::print('%WPM paging%w - e.g. 25/Eri (3) · now, later 1m/5m/2h/1d. The number beside the nick counts unread messages in that conversation. Up to 10 visible queries per network. New unread messages move the conversation to the top and reset only that network\'s page. Clickable ^ N and v N count hidden conversations; the wheel moves one entry. Identical nicknames on different networks have separate state.', $level);
    Irssi::print('%WAUTO-AWAY%w - OFF by default; automatic NOTICE and full report text are ON by default. A standalone mention of your nick anywhere in a message is highlighted and recorded while away. At most one automatic NOTICE per sender per 60 minutes. Enabled auto-away affects only networks without manual AWAY; channel activity clears only automatic AWAY. Reports light the relevant network Status. Mutes and spam/flood filters remain active.', $level);
    Irssi::print('%WCTCP%w - PING and VERSION work without a query, subject to flood limits. Replies to your /PING, /VER and /CTCP nick PING|VERSION are accepted from the requested nick for 15 s.', $level);
    Irssi::print('%WMAP%w - background MAP is hidden; a manual /MAP remains visible in Status 1. Outgoing /MSG opens your query; incoming PMs do not steal focus.', $level);
    Irssi::print('%WUnread channels%w - #channel (12) counts incoming messages and /me since the last read at the bottom. ! marks a mention of your nick. JOIN/PART/MODE, own messages and muted channels are excluded. State is in memory: /reload preserves it; script unload or disconnect clears it.', $level);
    Irssi::print('%W/matrixfind fragment|network/name|number%w - find channels and PMs; one match opens it, multiple matches show a list. %W/matrixundo%w reverses the last mouse swap while the layout is unchanged. %W/set matrix_order_autosave ON|OFF%w controls /layout save and /save after swaps and undo (ON by default, also saves current Irssi settings).', $level);
    Irssi::print('%W/matrixformats%w - reapply nickname alignment and JOIN/PART/QUIT/KICK/NICK/TOPIC/MODE/SYNC formats.', $level);
    Irssi::print('%WUnload%w - restores native Irssi status bars and the input prompt; a saved MATRIX layout or empty bar falls back to Irssi defaults. Run /save after unloading to keep the restored layout on disk.', $level);
    Irssi::print('%WEvent colours%w - JOIN >>> fades from gray to bright; PART <<< reverses this. Only <<< is red for QUIT and KICK; QUIT nicknames match ident@host. NICK/MODE/TOPIC/SYNC/NAMES use the monochrome layout.', $level);
    Irssi::print('GNU Screen: startup remains paused. Typing effects require /matrix screen input on and /matrix input on. Channel/JOIN animations require /matrix screen on and /matrix on; the selected WINDOW/JOIN mode is preserved.', $level) if netbar_matrix_in_screen();
}

sub matrix_command_help {
    matrix_print_help();
}

Irssi::command_bind('matrixhelp', 'matrix_command_help');

sub matrix_print_loaded {
    my $level = Irssi::MSGLEVEL_CLIENTCRAP();
    Irssi::print(
        '%R[%WMATRIX ' . $VERSION
        . '%R]%W Website: %Uhttps://matrix.ipv4.pl%U',
        $level,
    );
}

sub matrix_remove_top_level_callbacks {
    Irssi::signal_remove('channel topic changed', 'matrix_topic_changed');
    Irssi::signal_remove(
        'setup reread',
        'matrix_schedule_event_formats_reapply',
    );
    Irssi::signal_remove(
        'theme created',
        'matrix_schedule_event_formats_reapply',
    );
    for my $signal (
        'window changed',
        'window changed automatic',
        'window item changed',
        'window item server changed',
        'server connected',
        'server disconnected',
    ) {
        Irssi::signal_remove($signal, 'matrix_redraw_prompt_context');
    }
    Irssi::signal_remove(
        'server disconnected',
        'matrix_query_hostmask_server_disconnected',
    );
    for my $signal ('server connected', 'channel created', 'channel joined') {
        Irssi::signal_remove($signal, 'matrix_schedule_layout_settle');
    }

    Irssi::command_unbind('matrixformats', 'matrix_command_formats');
    Irssi::command_unbind('nickalign',     'matrix_command_nickalign');
    Irssi::command_unbind('matrixhelp',    'matrix_command_help');
}

sub UNLOAD {
    return if $matrix_unload_started;
    $matrix_unload_started = 1;
    $matrix_unloading = 1;
    for my $binding (matrix_visual_signals()) {
        Irssi::signal_remove($binding->[0], $binding->[1]);
    }
    %matrix_pm_last_incoming = ();
    %matrix_pm_recent = ();
    %matrix_pm_page_offset = ();
    %matrix_visual_pending = ();
    %matrix_mentions = ();
    %matrix_visual_waiting_query = ();
    matrix_remove_top_level_callbacks();

    Irssi::timeout_remove($matrix_dark_tick_tag) if $matrix_dark_tick_tag;
    Irssi::timeout_remove($matrix_bottom_rows_redraw_tag)
        if defined $matrix_bottom_rows_redraw_tag;
    Irssi::timeout_remove($matrix_layout_settle_tag)
        if defined $matrix_layout_settle_tag;
    Irssi::timeout_remove($matrix_event_formats_reapply_tag)
        if defined $matrix_event_formats_reapply_tag;
    for my $timer (@matrix_startup_settle_tags) {
        Irssi::timeout_remove($timer) if $timer;
    }
    @matrix_startup_settle_tags = ();
    undef $matrix_dark_tick_tag;
    undef $matrix_bottom_rows_redraw_tag;
    undef $matrix_layout_settle_tag;
    undef $matrix_event_formats_reapply_tag;
    matrix_cleanup_temp_snapshot($_) for keys %matrix_temp_snapshot_dirs;

    eval { nicklist_module_unload(); };
    Irssi::print('matrix cleanup warning: ' . $@) if $@;
    eval { pmu_module_unload(); };
    Irssi::print('matrix cleanup warning: ' . $@) if $@;
    eval { guard_module_unload(); };
    Irssi::print('matrix cleanup warning: ' . $@) if $@;
    eval { prvs_module_unload(); };
    Irssi::print('matrix cleanup warning: ' . $@) if $@;
    eval { clients_module_unload(); };
    Irssi::print('matrix cleanup warning: ' . $@) if $@;
    eval { netbar_module_unload(); };
    Irssi::print('matrix cleanup warning: ' . $@) if $@;

    # Remove script-owned configuration nodes as well as hiding the rows.
    # Saving after UNLOAD must not retain bars whose callbacks no longer exist.
    matrix_disable_owned_statusbars(1);

    # Restore formats/settings only after MATRIX's setup-changed listeners and
    # module timers have been removed. This prevents cleanup from immediately
    # rebuilding the interface which is being dismantled.
    matrix_restore_event_formats();
    matrix_restore_message_timestamp_format();

    # Restore a usable pre-load native layout, or Irssi defaults when the
    # snapshot contains a saved MATRIX layout or an earlier empty statusbar.
    matrix_restore_saved_statusbars();
    Irssi::command('^redraw');
}

for my $binding (matrix_visual_signals()) {
    if ($binding->[2]) {
        Irssi::signal_add_first($binding->[0], $binding->[1]);
    }
    else {
        Irssi::signal_add_last($binding->[0], $binding->[1]);
    }
}
matrix_print_loaded();
netbar_matrix_schedule_startup();

1;
