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_WRONLY O_NONBLOCK O_CREAT O_EXCL);
use Encode qw(decode FB_CROAK);

# 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 $MXL_O_NOFOLLOW = eval { Fcntl::O_NOFOLLOW() } || 0;

our $VERSION = '4.9.33';
our %IRSSI = (
    authors     => 'mxl',
    contact     => 'oskar@ipv4.pl',
    name        => 'mxl',
    description => 'Dual-mode IRC dashboard with the automatic 3.5.71 left sidebar and the complete 4.9.x bottom MXL-PUBLIC layout, hardened mouse/keyboard navigation, Matrix transitions, MAP statistics and an embedded SCREEN/FIFO nicklist',
    license     => 'GPLv2 (embedded nicklist component by Wouter Coekaerts)',
    commands    => 'mxlhelp mxlformats nickalign gaway mute unmute mxlnetnext mxlnavup mxlnavdown mxlnavleft mxlnavright mxlmouse mxlsidebar mxlcompact mxlnormal matrix pmclear pmunread pmtest prvguard netbars clientsreset clientsmap nicklist',
);

# Public-release state snapshot. Preserve every native surface which MXL changes
# so unloading the script returns the user to the exact runtime configuration
# which was active before MXL loaded.
my %mxl_saved_key_bindings;
my %mxl_saved_event_formats;
my %mxl_saved_statusbars;
my $mxl_saved_event_formats_complete = 0;
my $mxl_saved_statusbars_complete = 0;
my $mxl_public_state_snapshotted = 0;
my $mxl_mouse_raw_bindings_installed = 0;

sub mxl_config_unescape {
    my ($value) = @_;
    return '' unless defined $value;
    $value =~ s/\\n/\n/g;
    $value =~ s/\\r/\r/g;
    $value =~ s/\\t/\t/g;
    $value =~ s/\\"/"/g;
    $value =~ s/\\\\/\\/g;
    return $value;
}

sub mxl_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 mxl_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;

    for (1 .. 8) {
        my $candidate = $dir . '/.mxl-' . $label . '-' . $$ . '-'
            . int(rand(1_000_000_000)) . $suffix;
        next if -e $candidate || -l $candidate;
        return $candidate;
    }
    return '';
}

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

    my $ok = eval {
        Irssi::command('^save ' . mxl_irssi_quote_arg($path));
        1;
    };
    if (!$ok || !-f $path || -l $path || !-r $path) {
        unlink $path if -f $path && !-l $path;
        return '';
    }

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

sub mxl_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 mxl_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 mxl_extract_braced_block_at($text, $open);
    }
    return undef;
}

sub mxl_config_scalar {
    my ($block, $name) = @_;
    return undef unless defined($block) && defined($name);
    if ($block =~ /\b\Q$name\E\s*=\s*"((?:\\.|[^"\\])*)"\s*;/s) {
        return mxl_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 mxl_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) ? mxl_config_unescape($1) : $2;
        my $open = pos($block) - 1;
        my $inner = mxl_extract_braced_block_at($block, $open);
        last unless defined $inner;
        push @children, [ $name, $inner ];
        pos($block) = $open + length($inner) + 2;
    }
    return @children;
}

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

    my $statusbar = mxl_find_named_config_block($text, 'statusbar');
    return 0 unless defined $statusbar;
    my $default = mxl_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 = mxl_find_named_config_block($default, $bar);
        next unless defined $bar_block;

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

        my $items_block = mxl_find_named_config_block($bar_block, 'items');
        my @items;
        if (defined $items_block) {
            for my $child (mxl_config_direct_named_blocks($items_block)) {
                my ($name, $item_block) = @$child;
                next unless defined($name)
                    && $name =~ /\A[A-Za-z0-9_.:+\/-]+\z/;
                my $priority = mxl_config_scalar($item_block, 'priority');
                $priority = 0 unless defined($priority) && $priority =~ /\A-?\d+\z/;
                my $alignment = lc(mxl_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;
        $mxl_saved_statusbars{$bar} = \%saved;
        $found++;
    }

    $mxl_saved_statusbars_complete = $found == 4 ? 1 : 0;
    return $found;
}

sub mxl_snapshot_key_bindings_from_config {
    my ($text) = @_;
    return unless defined($text) && length($text);
    my %wanted = map { $_ => 1 } ('^X', 'mleft', 'mright', 'mup', 'mdown', 'meta-[M', 'meta-[<');

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

sub mxl_snapshot_event_formats_runtime;
sub mxl_snapshot_event_formats_from_text;

sub mxl_snapshot_public_runtime_state {
    return if $mxl_public_state_snapshotted;
    $mxl_public_state_snapshotted = 1;

    my $config = mxl_read_runtime_config_snapshot();
    $config = mxl_read_persisted_config() unless length $config;
    if (length $config) {
        mxl_snapshot_key_bindings_from_config($config);
        mxl_snapshot_statusbars_from_text($config);
    }

    # /SAVE -formats writes the currently active runtime formats, including
    # unsaved /FORMAT changes. Snapshot them before MXL applies its overrides.
    # If a particular Irssi build cannot create the temporary theme, fall back
    # to any persisted format overrides found in the normal config.
    mxl_snapshot_event_formats_runtime();
    mxl_snapshot_event_formats_from_text($config)
        if !$mxl_saved_event_formats_complete && length($config);
}

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

sub mxl_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 mxl_terminal_plain_text {
    my ($text) = @_;
    $text = mxl_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 mxl_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 mxl_text_truncate_cells {
    my ($text, $cells) = @_;
    $text = '' unless defined $text;
    $cells = int($cells // 0);
    return '' if $cells <= 0;
    return $text if mxl_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 mxl_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(?:MXLNETNEXT|MXLNAVLEFT|MXLNAVRIGHT|MXLNAVUP|MXLNAVDOWN)\z/
        ? 1 : 0;
}

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

    # A stale binding left by an older MXL 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 || mxl_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 .= ' ' . mxl_irssi_quote_arg($data) if length $data;
    Irssi::command($command);
}

sub mxl_restore_saved_key_bindings {
    for my $key ('^X', 'mleft', 'mright', 'mup', 'mdown') {
        mxl_restore_one_saved_binding($key, $mxl_saved_key_bindings{$key});
    }

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

sub mxl_restore_saved_statusbars {
    my @bars = qw(window window_inact prompt topic);
    if (!$mxl_saved_statusbars_complete) {
        Irssi::command('^statusbar reset ' . $_) for @bars;
        return;
    }

    my @known_items = qw(
        barstart barend topicbarstart topicbarend time user window window_empty
        prompt prompt_empty input lag act more topic topic_empty
        mxl_prompt_context mxl_sidebar_topic mxl_sidebar_clock_pm
        mxl_sidebar_act_overflow mxl_dark_fill mxl_clock mxl_dashboard
        pm_unread prv_status clients_activity
    );

    for my $bar (@bars) {
        my $saved = $mxl_saved_statusbars{$bar};
        next unless $saved;

        Irssi::command('^statusbar reset ' . $bar);
        my %remove = map { $_ => 1 } @known_items;
        $remove{$_->{name}} = 1 for @{ $saved->{items} || [] };
        for my $item (sort keys %remove) {
            Irssi::command('^statusbar removeitem ' . $item . ' ' . $bar);
        }

        my $disabled = lc($saved->{disabled} // 'no');
        my @options = ($disabled eq 'yes' ? '-disable' : '-nodisable');
        push @options, '-type', $saved->{type}
            if defined($saved->{type}) && $saved->{type} =~ /\A(?:window|root)\z/;
        push @options, '-placement', $saved->{placement}
            if defined($saved->{placement}) && $saved->{placement} =~ /\A(?:top|bottom)\z/;
        push @options, '-position', mxl_irssi_quote_arg($saved->{position})
            if defined($saved->{position}) && $saved->{position} =~ /\A-?\d+\z/;
        push @options, '-visible', $saved->{visible}
            if defined($saved->{visible}) && $saved->{visible} =~ /\A(?:always|active|inactive)\z/;
        Irssi::command('^statusbar modify ' . join(' ', @options) . ' ' . $bar);

        my $previous = '';
        for my $item (@{ $saved->{items} || [] }) {
            my $command = '^statusbar additem -alignment ' . $item->{alignment}
                . ' -priority ' . mxl_irssi_quote_arg($item->{priority});
            $command .= ' -after ' . $previous if length $previous;
            $command .= ' ' . $item->{name} . ' ' . $bar;
            Irssi::command($command);
            $previous = $item->{name};
        }
    }
}

sub mxl_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 'MXLMOUSEXTERM';
    return 1 if $key eq 'meta-[<' && $data eq 'MXLMOUSESGR';
    return 0;
}

sub mxl_saved_external_mouse_binding_present {
    for my $key ('meta-[M', 'meta-[<') {
        my $saved = $mxl_saved_key_bindings{$key};
        next unless $saved;
        return 1 unless mxl_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 $mxl_dark_tick_tag;
my $mxl_layout_settle_tag;
my $mxl_pm_pulse_tag;
my @mxl_startup_settle_tags;
my @mxl_mode_settle_tags;
my $mxl_chrome_heal_tag;
my $mxl_mode_settle_generation = 0;
my $mxl_unloading = 0;
my $mxl_pm_pulse_on = 1;
my $mxl_previous_timestamp_format;
my $mxl_timestamp_format_saved = 0;

# Force ordinary Irssi lines to use a one-cell indent and a timestamp with
# seconds. The clock in the custom statusbar is rendered separately below.
# Preserve the user's original value so a real /SCRIPT UNLOAD is reversible.
sub mxl_apply_message_timestamp_format {
    if (!$mxl_timestamp_format_saved) {
        $mxl_previous_timestamp_format =
            Irssi::settings_get_str('timestamp_format');
        $mxl_timestamp_format_saved = 1;
    }
    Irssi::settings_set_str('timestamp_format', ' [%H:%M:%S]');
    # Irssi caches the rendered timestamp format. Refresh it immediately.
    Irssi::signal_emit('setup changed');
}

sub mxl_restore_message_timestamp_format {
    return unless $mxl_timestamp_format_saved;
    Irssi::settings_set_str(
        'timestamp_format',
        defined($mxl_previous_timestamp_format)
            ? $mxl_previous_timestamp_format
            : '',
    );
    $mxl_timestamp_format_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 MXL 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('mxl', 'mxl_nick_alignment', 'right');

my %mxl_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 mxl_nick_alignment_mode {
    my $mode = lc(Irssi::settings_get_str('mxl_nick_alignment') // 'right');
    return $mode eq 'left' ? 'left' : 'right';
}

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

my @mxl_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.

    # timestamp_format already contains the brackets. Render the complete
    # public-message timestamp dark gray, then reset the message colour.
    ['core', 'timestamp',
        '%K$Z%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: <+      mxl>.
    ['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 mxl_event_format_aliases {
    my ($module) = @_;
    return ($module, 'fe-common/' . $module);
}

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

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

    my $found = 0;
    for my $entry (@mxl_event_formats) {
        my ($module, $key) = @$entry;
        my $value;
        for my $alias (mxl_event_format_aliases($module)) {
            my $module_block = mxl_find_named_config_block($formats, $alias);
            next unless defined $module_block;
            $value = mxl_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]/;
        $mxl_saved_event_formats{lc($module) . "\0" . $key} = $value;
        $found++;
    }

    $mxl_saved_event_formats_complete = 1
        if $found == scalar(@mxl_event_formats);
    return $found;
}

sub mxl_snapshot_event_formats_runtime {
    my $path = mxl_temp_snapshot_path('formats', '.theme');
    return 0 unless length $path;

    my $ok = eval {
        Irssi::command('^save -formats ' . mxl_irssi_quote_arg($path));
        1;
    };
    if (!$ok || !-f $path || -l $path || !-r $path) {
        unlink $path if defined($path) && -f $path && !-l $path;
        return 0;
    }

    open my $fh, '<', $path or do { unlink $path; return 0; };
    local $/;
    my $text = <$fh>;
    close $fh;
    unlink $path;

    return mxl_snapshot_event_formats_from_text(
        defined($text) ? $text : ''
    );
}

# 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.
mxl_snapshot_public_runtime_state();

my $mxl_event_formats_reapply_tag;

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

sub mxl_restore_event_formats {
    Irssi::timeout_remove($mxl_event_formats_reapply_tag)
        if defined $mxl_event_formats_reapply_tag;
    undef $mxl_event_formats_reapply_tag;

    for my $entry (@mxl_event_formats) {
        my ($module, $key) = @$entry;
        my $saved_key = lc($module) . "\0" . $key;
        if (exists $mxl_saved_event_formats{$saved_key}) {
            my $value = $mxl_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 mxl_run_event_formats_reapply {
    undef $mxl_event_formats_reapply_tag;
    mxl_apply_event_formats();
    mxl_apply_dynamic_prompt() if defined &mxl_apply_dynamic_prompt;
}

sub mxl_command_formats {
    mxl_apply_event_formats();
    mxl_apply_dynamic_prompt() if defined &mxl_apply_dynamic_prompt;
    my $mode = mxl_nick_alignment_mode() eq 'left'
        ? 'do lewej (naturalne Irssi)'
        : 'do prawej (pole 9 znakow)';
    Irssi::print('MXL: zastosowano timestamp, wyrownanie nickow ' . $mode . ', dynamiczny PROMPT oraz formaty JOIN/PART/QUIT/KICK/NICK/TOPIC/MODE/SYNC.', Irssi::MSGLEVEL_CLIENTCRAP());
}

sub mxl_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 = mxl_nick_alignment_mode();
        Irssi::print(
            $mode eq 'left'
                ? 'MXL nickalign: LEFT - naturalne wyrownanie Irssi, bez paddingu.'
                : 'MXL nickalign: RIGHT - nicki wyrownane do prawej w polu 9 znakow.'
        );
        return;
    }

    my $mode;
    if ($arg eq '' || $arg eq 'toggle') {
        $mode = mxl_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]  (bez argumentu = przelacz)');
        return;
    }

    Irssi::settings_set_str('mxl_nick_alignment', $mode);
    mxl_apply_event_formats();
    Irssi::command('^redraw');
    Irssi::print(
        $mode eq 'left'
            ? 'MXL nickalign: LEFT - naturalne wyrownanie Irssi, bez paddingu.'
            : 'MXL nickalign: RIGHT - nicki wyrownane do prawej w polu 9 znakow.'
    );
}

Irssi::command_bind('mxlformats', 'mxl_command_formats');
Irssi::command_bind('nickalign', 'mxl_command_nickalign');

sub mxl_schedule_event_formats_reapply {
    Irssi::timeout_remove($mxl_event_formats_reapply_tag)
        if defined $mxl_event_formats_reapply_tag;
    $mxl_event_formats_reapply_tag = Irssi::timeout_add_once(
        250,
        'mxl_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', 'mxl_schedule_event_formats_reapply');
Irssi::signal_add_last('theme created', 'mxl_schedule_event_formats_reapply');


# The built-in prompt item receives only the active target (for example
# #contempt). Replace only that label with a custom item showing
# [network/target]; the adjacent built-in input item keeps normal editing.

# Left-sidebar prompt and chrome retained from the 3.5.71 layout.

# The built-in statusbar item named "prompt" receives only the active target
# (for example #atw) as its theme argument. Changing /FORMAT core prompt cannot
# prepend the network reliably, because the visible [#atw] comes from the theme
# abstract used by that statusbar item. Replace only that item with a Perl item;
# the adjacent built-in "input" item remains untouched and keeps the cursor and
# editing behavior provided by Irssi.
sub mxl_prompt_safe_text {
    my ($text) = @_;
    $text = mxl_terminal_plain_text($text);
    $text =~ tr/{}/()/;
    $text =~ s/%/%%/g;
    return $text;
}

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

    my $active = $window->{active};
    return unless $active;

    my $server = $active->{server} || $window->{active_server};
    return unless $server;

    my $network = $server->{chatnet} || $server->{tag} || '';
    my $target = $active->{visible_name} || $active->{name} || '';
    return unless length($network) && length($target);

    return ($network, $target);
}

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

    my ($network, $target) = mxl_prompt_network_target();
    if (!defined($network) || !defined($target)) {
        if ($get_size_only) {
            $item->{min_size} = 0;
            $item->{max_size} = 0;
        }
        return;
    }

    my $label = mxl_prompt_safe_text($network . '/' . $target);
    my $indent = defined(&netbar_sidebar_mode) && netbar_sidebar_mode()
        ? ' '
        : '';
    my $text = '%0%W' . $indent . '%K[%W' . $label . '%K]%n ';
    $item->default_handler($get_size_only, $text, '', 1);
}

sub mxl_redraw_prompt_context {
    # A window/active-item switch does not automatically invalidate custom
    # statusbar items. Redraw the complete conversation chrome together so a
    # channel topic cannot remain on a QUERY and no stale cell is exposed while
    # Irssi moves the input cursor to the newly selected window.
    for my $item (qw(
        mxl_prompt_context mxl_sidebar_topic mxl_sidebar_clock_pm
        mxl_sidebar_act_overflow mxl_dark_fill input
    )) {
        Irssi::statusbar_items_redraw($item);
    }
}

sub mxl_apply_dynamic_prompt {
    my $sidebar_prompt = defined(&netbar_sidebar_mode) && netbar_sidebar_mode();
    if ($sidebar_prompt) {
        Irssi::command(
            '^statusbar modify -nodisable -type window -placement bottom '
            . '-position 100 -visible active prompt'
        );
    }
    else {
        Irssi::command(
            '^statusbar modify -nodisable -type root -placement bottom '
            . '-position 100 -visible always 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 this permanent order:
    #   active channel/query: mxl_prompt_context, input
    #   Status/empty window:  prompt_empty, input
    for my $item (qw(prompt prompt_empty mxl_prompt_context input)) {
        Irssi::command('^statusbar removeitem ' . $item . ' prompt');
    }

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

    Irssi::statusbar_items_redraw('mxl_prompt_context');
    Irssi::command('^redraw');
}

sub mxl_restore_builtin_prompt {
    Irssi::command(
        '^statusbar modify -nodisable -type root -placement bottom '
        . '-position 100 -visible always prompt'
    );
    for my $item (qw(mxl_prompt_context prompt prompt_empty input)) {
        Irssi::command('^statusbar removeitem ' . $item . ' prompt');
    }

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

Irssi::statusbar_item_register(
    'mxl_prompt_context',
    0,
    'mxl_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, 'mxl_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 mxl_dark_fill {
    my ($item, $get_size_only) = @_;
    return unless $item;

    if ($get_size_only) {
        # A zero-sized flexible item can be collapsed completely by Irssi when
        # a window bar is rebuilt.  The uncovered bar then falls back to the
        # theme background (normally blue).  Reserve one real cell so Irssi
        # assigns this item the remaining width and every cell is painted black.
        $item->{min_size} = 1;
        $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);
}

# Black replacement for the standard blue topic bar while sidebar mode is
# active. It preserves the active channel topic but paints every cell with a
# black background.
sub mxl_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 $active_type = $active ? uc($active->{type} // '') : '';
    my $is_channel = $active
        && ($active_type eq 'CHANNEL' || ref($active) =~ /Channel/i)
        ? 1
        : 0;
    my $topic_plain = $is_channel && defined($active->{topic})
        ? $active->{topic}
        : '';
    $topic_plain = mxl_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 = mxl_text_truncate_cells($topic_plain, $topic_room)
        if $width > 0 && mxl_text_width($topic_plain) > $topic_room;
    my $topic = $topic_plain;
    $topic =~ s/%/%%/g;
    my $padding = $width - 1 - mxl_text_width($topic_plain);
    $padding = 0 if $padding < 0;

    # The clock/Act row begins with one blank cell before [HH:MM:SS]. Use the
    # same origin for topic text so both visual anchors are exactly aligned.
    # Paint the rest ourselves as well; a flexible item which reserves the
    # complete row but emits no spaces can briefly expose the blue theme.
    $item->default_handler(
        0,
        '%0%W ' . $topic . (' ' x $padding),
        '',
        1,
    );
}

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

# One combined full-width row. The clock stays on the left, while the
# active-network MAP figures are aligned to the right on the same line. Private
# conversation indicators are rendered beside their own network headers.
sub mxl_clock {
    my ($item, $get_size_only) = @_;
    return unless $item;

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

    my $shown_time = strftime('%H:%M', localtime());
    $shown_time =~ s/%/%%/g;

    my $plain = '[' . $shown_time . ']';
    my $text  = '%0%W%K[%W' . $shown_time . '%K]';
    my $width = defined($item->{size}) ? int($item->{size}) : 0;

    my ($right_text, $right_plain_length) = ('', 0);
    if (defined &clients_dashboard_data) {
        ($right_text, $right_plain_length) = clients_dashboard_data();
    }

    if ($right_plain_length > 0
        && $width >= length($plain) + $right_plain_length + 1) {
        my $padding = $width - length($plain) - $right_plain_length;
        $padding = 1 if $padding < 1;
        $text .= (' ' x $padding) . $right_text;
    }
    else {
        my $padding = $width - length($plain);
        $padding = 0 if $padding < 0;
        $text .= ' ' x $padding;
    }

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

# Legacy standalone MAP-row renderer retained only for safe cleanup of old
# layouts. The visible clock and Network/IN/OUT/NET data now share mxl_clock.
sub mxl_dashboard {
    my ($item, $get_size_only) = @_;
    return unless $item;

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

    my ($right, $right_plain_length) = ('', 0);
    if (defined &clients_dashboard_data) {
        ($right, $right_plain_length) = clients_dashboard_data();
    }

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

    my $text = '%0%W' . (' ' x $padding) . $right . ' %0%W';
    $item->default_handler(0, $text, '', 1);
}

sub mxl_dark_tick {
    Irssi::statusbar_items_redraw('mxl_dashboard');
    Irssi::statusbar_items_redraw('mxl_clock');
    Irssi::statusbar_items_redraw('mxl_sidebar_clock_pm');
    Irssi::statusbar_items_redraw('mxl_sidebar_act_overflow');

    # In sidebar mode this also acts as a resize watchdog. Rendering is cheap
    # when nothing changed because the sidebar signature prevents CLEAR/print.
    # This keeps the width locked and immediately adapts the visible row count
    # after terminal width/height changes.
    if (defined &netbar_sidebar_mode && netbar_sidebar_mode()) {
        netbar_sidebar_resize_watchdog()
            if defined &netbar_sidebar_resize_watchdog;
    }
    else {
        netbar_schedule_refresh() if defined &netbar_schedule_refresh;
    }
}

my $MXL_SIDEBAR_PM_SCROLL_STEP = 3;
my $MXL_SIDEBAR_PM_VISIBLE_LIMIT = 6;
my $mxl_sidebar_pm_offset = 0;
my $mxl_sidebar_pm_last_has_next = 0;
my $mxl_sidebar_pm_last_available = -1;

# Return the complete live QUERY list. The two physical Act rows lay out up to
# six PMs against the real conversation-pane width; later PMs stay behind +N.
sub mxl_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)) {
                my $rendered = pmu_render_stack_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,
                };
            }
        }
    }

    my $total = scalar @all;
    $mxl_sidebar_pm_offset = 0
        if $total < 1
            || $mxl_sidebar_pm_offset < 0
            || $mxl_sidebar_pm_offset >= $total;

    return {
        all    => \@all,
        total  => $total,
        offset => $mxl_sidebar_pm_offset,
    };
}

sub mxl_sidebar_pm_adjust_page {
    my ($direction) = @_;
    my $data = mxl_sidebar_activity_data();
    my $total = int($data->{total} // 0);
    return if $total < 1;

    $direction = int($direction // 1);
    if ($direction < 0) {
        return if $mxl_sidebar_pm_offset <= 0;
        $mxl_sidebar_pm_offset -= $MXL_SIDEBAR_PM_SCROLL_STEP;
        $mxl_sidebar_pm_offset = 0 if $mxl_sidebar_pm_offset < 0;
    }
    else {
        return unless $mxl_sidebar_pm_last_has_next;
        $mxl_sidebar_pm_offset += $MXL_SIDEBAR_PM_SCROLL_STEP;
        $mxl_sidebar_pm_offset = $total - 1
            if $mxl_sidebar_pm_offset >= $total;
    }

    Irssi::statusbar_items_redraw('mxl_sidebar_clock_pm');
    Irssi::statusbar_items_redraw('mxl_sidebar_act_overflow');
    Irssi::command('^redraw');
}

# Build two width-aware Act rows. Real PMs fill both rows first. Only when more
# entries remain does the end of row two receive: < (back), +N (forward) and >
# (forward). Every navigation action moves exactly three logical QUERYs.
sub mxl_sidebar_activity_rows {
    my ($available) = @_;
    $available = int($available // 0);
    $available = 1 if $available < 1;

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

    if ($mxl_sidebar_pm_last_available > 0
        && $mxl_sidebar_pm_last_available != $available) {
        # A resize changes capacity. Restart from the stable beginning so a
        # narrowed pane is laid out evenly instead of retaining a stale offset.
        $mxl_sidebar_pm_offset = 0;
    }
    $mxl_sidebar_pm_last_available = $available;

    my $data = mxl_sidebar_activity_data();
    my @all = @{ $data->{all} || [] };
    my $total = int($data->{total} // 0);
    my $offset = int($data->{offset} // 0);
    my $clock_plain = ' [' . $shown_time . ']';
    my $clock_text  = '%0%W %K[%W' . $shown_time . '%K]';
    if ($total < 1) {
        $mxl_sidebar_pm_last_has_next = 0;
        return ($clock_text, $clock_plain, '%0%W', '', [], []);
    }

    my $prefix_plain = $clock_plain . ' Act: ';
    my $prefix_text  = $clock_text . ' %WAct:%K ';
    my $indent_plain = ' ' x mxl_text_width($prefix_plain);
    my $indent_text  = '%0%W' . $indent_plain;

    my $labels_plain_length = sub {
        my ($labels) = @_;
        my $length = 0;
        for my $index (0 .. $#$labels) {
            my $label = $labels->[$index];
            my $separator = $index
                ? ($label->{separator_plain} // ', ')
                : '';
            $length += mxl_text_width($separator)
                + mxl_text_width($label->{plain} // '');
        }
        return $length;
    };

    my (@first, @second);
    my $placed_count = 0;
    ENTRY:
    for my $source_index ($offset .. $total - 1) {
        last ENTRY if $placed_count >= $MXL_SIDEBAR_PM_VISIBLE_LIMIT;
        my $source = $all[$source_index];
        next unless $source;

        for my $row_index (0, 1) {
            my $target = $row_index == 0 ? \@first : \@second;
            my $base_length = $row_index == 0
                ? mxl_text_width($prefix_plain)
                : mxl_text_width($indent_plain);
            my $separator_length = @$target ? 2 : 0;
            my $room = $available - $base_length
                - $labels_plain_length->($target) - $separator_length;
            next if $room < 1;

            my $label = { %$source };
            if (mxl_text_width($label->{plain} // '') > $room) {
                # Do not squeeze a normal label into the tail of an occupied
                # row; wrap it first. Only a single overlong PM is truncated.
                next if @$target;
                my $rendered = pmu_render_stack_entry(
                    $label->{source_entry},
                    $room,
                );
                next unless $rendered && length($rendered->{plain} // '');
                $label->{plain} = $rendered->{plain};
                $label->{text}  = $rendered->{text};
            }

            push @$target, $label;
            $placed_count++;
            next ENTRY;
        }

        # Preserve logical order. If this entry does not fit, every following
        # entry belongs behind the navigation control as well.
        last ENTRY;
    }

    # Navigation is appended only after both rows have first been filled with
    # real PMs. If necessary, remove entries from the end of row two to reserve
    # compact controls there; they remain available on the next three-step view.
    while (1) {
        $placed_count = scalar(@first) + scalar(@second);
        my $hidden_after = $total - $offset - $placed_count;
        $hidden_after = 0 if $hidden_after < 0;
        my $has_previous = $offset > 0 ? 1 : 0;
        my $has_next = $hidden_after > 0 ? 1 : 0;
        my @controls;

        if ($has_previous) {
            push @controls, {
                plain            => '<',
                text             => '%W<%K',
                type             => 'PM_PREV',
                sidebar_activity => 1,
                separator_plain  => '  ',
                separator_text   => '%K  ',
            };
        }

        if ($has_next) {
            my $hidden_unread = 0;
            for my $index ($offset + $placed_count .. $total - 1) {
                if ($all[$index] && $all[$index]->{is_unread}) {
                    $hidden_unread = 1;
                    last;
                }
            }
            my $colour = $hidden_unread
                ? ($mxl_pm_pulse_on ? '%W' : '%K')
                : '%w';
            push @controls, {
                plain            => '+' . $hidden_after,
                text             => $colour . '+' . $hidden_after . '%K',
                type             => 'PM_MORE',
                sidebar_activity => 1,
                separator_plain  => @controls ? ' ' : '  ',
                separator_text   => @controls ? '%K ' : '%K  ',
            };
            push @controls, {
                plain            => '>',
                text             => '%W>%K',
                type             => 'PM_NEXT',
                sidebar_activity => 1,
                separator_plain  => ' ',
                separator_text   => '%K ',
            };
        }

        if (!@controls) {
            $mxl_sidebar_pm_last_has_next = 0;
            last;
        }

        my @candidate = (@second, @controls);
        if (mxl_text_width($indent_plain) + $labels_plain_length->(\@candidate)
            <= $available) {
            @second = @candidate;
            $mxl_sidebar_pm_last_has_next = $has_next;
            last;
        }

        if (@second) {
            pop @second;
            next;
        }

        # Extremely narrow panes may not have room for the normal indentation.
        # Keep navigation clickable from column zero rather than clipping it.
        if (length($indent_plain)) {
            $indent_plain = '';
            $indent_text = '%0%W';
            next;
        }

        # Last-resort compact control: +N still moves forward, while < still
        # returns to the previous three entries.
        my $compact = $has_next ? $controls[-2] : $controls[0];
        @second = mxl_text_width($compact->{plain}) <= $available ? ($compact) : ();
        $mxl_sidebar_pm_last_has_next = $has_next;
        last;
    }

    my $build_row = sub {
        my ($base_plain, $base_text, $labels) = @_;
        my $plain = $base_plain;
        my $text = $base_text;
        my @segments;
        for my $index (0 .. $#$labels) {
            my $label = $labels->[$index];
            my $separator_plain = $index
                ? ($label->{separator_plain} // ', ')
                : '';
            my $separator_text = $index
                ? ($label->{separator_text} // '%K, ')
                : '';
            $plain .= $separator_plain;
            $text .= $separator_text;
            my $start = mxl_text_width($plain);
            $plain .= $label->{plain};
            $text .= $label->{text};
            my $label_width = mxl_text_width($label->{plain});
            push @segments, {
                x_start          => $start,
                x_end            => $start + $label_width - 1,
                type             => $label->{type} // '',
                tag              => $label->{tag} // '',
                name             => $label->{name} // '',
                sidebar_activity => $label->{sidebar_activity} ? 1 : 0,
                immediate_close  => $label->{immediate_close} ? 1 : 0,
            };
        }
        return ($text, $plain, \@segments);
    };

    my ($first_text, $first_plain, $first_segments) =
        $build_row->($prefix_plain, $prefix_text, \@first);
    my ($second_text, $second_plain, $second_segments) =
        @second
            ? $build_row->($indent_plain, $indent_text, \@second)
            : ('%0%W', '', []);

    return (
        $first_text,
        $first_plain,
        $second_text,
        $second_plain,
        $first_segments,
        $second_segments,
    );
}

# Main [HH:MM:SS] Act: row used by sidebar mode.
sub mxl_sidebar_clock_pm {
    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} = mxl_text_width($clock_plain);
        # Claim the real conversation width. Previously max_size was derived
        # from six labels, while mouse hitboxes used the full pane width; that
        # mismatch made a visible +N impossible to click.
        $item->{max_size} = 4096;
        return;
    }

    my $available = defined($item->{size})
        ? int($item->{size})
        : 4096;
    my ($text, $plain) = mxl_sidebar_activity_rows($available);
    my $padding = $available - mxl_text_width($plain // '');
    $padding = 0 if $padding < 0;
    $item->default_handler(
        0,
        $text . '%0%W' . (' ' x $padding),
        '',
        1,
    );
}

# Permanently reserved row below Act. It stays blank when everything fits and
# automatically receives whole nr/nick entries when the first row is full.
sub mxl_sidebar_act_overflow {
    my ($item, $get_size_only) = @_;
    return unless $item;

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

    my $available = defined($item->{size}) ? int($item->{size}) : 1;
    my (undef, undef, $text, $plain) =
        mxl_sidebar_activity_rows($available);
    my $padding = $available - mxl_text_width($plain // '');
    $padding = 0 if $padding < 0;
    $item->default_handler(
        0,
        $text . '%0%W' . (' ' x $padding),
        '',
        1,
    );
}

Irssi::statusbar_item_register('mxl_dark_fill', 0, 'mxl_dark_fill');
Irssi::statusbar_item_register('mxl_clock',     0, 'mxl_clock');
Irssi::statusbar_item_register('mxl_dashboard', 0, 'mxl_dashboard');
Irssi::statusbar_item_register(
    'mxl_sidebar_clock_pm',
    0,
    'mxl_sidebar_clock_pm',
);
Irssi::statusbar_item_register(
    'mxl_sidebar_act_overflow',
    0,
    'mxl_sidebar_act_overflow',
);

sub mxl_apply_dark_window_bar {
    # Keep the technical row used by Irssi's bottom layout, but paint both its
    # active and inactive variants fully black.  Styling only ``window`` left a
    # blue ``window_inact`` row behind after leaving the sidebar split.
    for my $bar (qw(window window_inact)) {
        for my $item (qw(
            barstart barend time user window window_empty lag act more
            mxl_light_bar_start mxl_light_bar_end mxl_light_time
            mxl_light_activity mxl_white_fill mxl_dark_fill mxl_clock
            pm_unread prv_status
        )) {
            Irssi::command('^statusbar removeitem ' . $item . ' ' . $bar);
        }

        Irssi::command(
            '^statusbar additem -alignment left -priority 1000 '
            . 'mxl_dark_fill ' . $bar
        );
        my $visibility = $bar eq 'window' ? 'active' : 'inactive';
        Irssi::command(
            '^statusbar modify -nodisable -type window -placement bottom '
            . '-position 0 -visible ' . $visibility . ' ' . $bar
        );
    }
    Irssi::command('^redraw');
}

# One fully black empty row directly above the combined clock/dashboard row.
# The clock is at -102, therefore -103 leaves one visible line of breathing room
# between normal chat output and the custom statusbar layout.
sub mxl_apply_dark_top_gap_bar {
    my $bar = 'mxl_top_gap_bar';

    Irssi::command(
        '^statusbar add -disable -type window -placement bottom '
        . '-position "-103" -visible always ' . $bar
    );

    for my $item (qw(
        barstart barend mxl_light_bar_start mxl_light_bar_end
        mxl_white_fill mxl_clock mxl_dashboard mxl_dark_fill
        clients_activity
    )) {
        Irssi::command('^statusbar removeitem ' . $item . ' ' . $bar);
    }

    Irssi::command(
        '^statusbar additem -alignment left -priority 1000 '
        . 'mxl_dark_fill ' . $bar
    );
    Irssi::command(
        '^statusbar modify -nodisable -type window -placement bottom '
        . '-position "-103" -visible always ' . $bar
    );
}

sub mxl_apply_dark_clock_bar {
    my $clock_bar = 'mxl_clock_bar';

    # Combined clock/PM row with Network/IN/OUT/NET aligned on its right side.
    Irssi::command(
        '^statusbar add -disable -type window -placement bottom '
        . '-position "-102" -visible always ' . $clock_bar
    );
    for my $item (qw(
        barstart barend mxl_light_time mxl_white_fill
        mxl_light_bar_start mxl_light_bar_end mxl_clock mxl_dashboard
        mxl_dark_fill clients_activity
    )) {
        Irssi::command('^statusbar removeitem ' . $item . ' ' . $clock_bar);
    }
    Irssi::command(
        '^statusbar additem -alignment left -priority 1000 mxl_clock '
        . $clock_bar
    );
    Irssi::command(
        '^statusbar modify -nodisable -type window -placement bottom '
        . '-position "-102" -visible always ' . $clock_bar
    );
}


# One fully black separator row between the clock/PM row and the first
# network row. Network bars start at -100, so this row permanently occupies -101.
sub mxl_apply_dark_network_gap_bar {
    my $bar = 'mxl_network_gap_bar';

    Irssi::command(
        '^statusbar add -disable -type window -placement bottom '
        . '-position "-101" -visible always ' . $bar
    );

    for my $item (qw(
        barstart barend mxl_light_bar_start mxl_light_bar_end
        mxl_white_fill mxl_clock mxl_dashboard mxl_dark_fill
        clients_activity
    )) {
        Irssi::command('^statusbar removeitem ' . $item . ' ' . $bar);
    }

    Irssi::command(
        '^statusbar additem -alignment left -priority 1000 '
        . 'mxl_dark_fill ' . $bar
    );
    Irssi::command(
        '^statusbar modify -nodisable -type window -placement bottom '
        . '-position "-101" -visible always ' . $bar
    );
}


# ============================================================================
# pm_unread.pl
# ============================================================================
{
# Unread entries are stored as: network-tag + nick.
my %unread;
my %pmu_focus_timers;
my $pmu_previous_autocreate_own_query;
my $pmu_gc_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) = @_;
    return lc(defined $nick ? $nick : '');
}

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

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) eq pmu_normalise_nick($own_nick);
}

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}) eq pmu_normalise_nick($nick);
}

sub pmu_query_is_visible {
    my ($server, $nick) = @_;
    my $window = Irssi::active_win();
    return 0 unless defined $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;
    return lc($tag) . "\0" . pmu_normalise_nick($nick);
}

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 $server->{chatnet} || $server->{tag} || $tag 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;
}

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_has_unread_alerts {
    return scalar(keys %unread) ? 1 : 0;
}

sub pmu_redraw_statusbar {
    # PM information now lives inside the network-header rows. Keep the legacy
    # standalone item and clock redrawn as harmless compatibility cleanup, then
    # rebuild the network rows so every alert appears beside the correct network.
    Irssi::statusbar_items_redraw('pm_unread');
    Irssi::statusbar_items_redraw('mxl_clock');
    Irssi::statusbar_items_redraw('mxl_sidebar_clock_pm');
    Irssi::statusbar_items_redraw('mxl_sidebar_act_overflow');
    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};
        $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
                ? ($mxl_pm_pulse_on ? '%M' : '%K')
                : '%w';
            my $last_plain = $refnum . '/' . $nick;
            $last_label = {
                plain => $last_plain,
                # Only the window number blinks; slash and nick stay unchanged.
                text  => $number_colour . $refnum . '%K/%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 = $mxl_pm_pulse_on ? '%M' : '%K';

        my $plain = defined($refnum) ? $refnum . '/' . $nick : $nick;
        my $text = defined($refnum)
            ? $number_colour . $refnum . '%K/%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, [])
        : ('', '', []);
}

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. Act: uses 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 = mxl_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;
    my $number_colour = $is_unread
        ? ($mxl_pm_pulse_on ? '%M' : '%K')
        : ($visible ? '%W' : '%w');

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

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

    # Unread state affects only the colour of the window number. It must not
    # affect the position of a QUERY in Act:.
    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_keys = map {
        pmu_query_key_from_parts($_->{tag}, $_->{nick}) => 1
    } @unread_entries;

    # Collect every live QUERY once. The query list is then sorted only by its
    # real Irssi window number, so selecting 23/makumba cannot move it ahead of
    # 22/pietcin. A nickname is used only as a deterministic tie-breaker.
    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 {
        $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,
        );
        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 - mxl_text_width($prefix_plain);

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

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

    my $plain = $prefix_plain . $nick_plain;
    my $text;
    if (length($prefix_plain)) {
        # New PMs pulse only their window number in magenta/dark gray.
        # The slash and nickname remain steady, exactly as before.
        $text = ($entry->{number_colour} // '%w')
            . int($entry->{refnum}) . '%K/%W'
            . pmu_safe_status_text($nick_plain) . '%K';
    }
    else {
        $text = '%W' . pmu_safe_status_text($nick_plain) . '%K';
    }

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

# Build compact Act rows. The first configured batch (normally five 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.
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 - mxl_text_width($row->{plain})
            - mxl_text_width($separator_plain);
        if (length($row->{plain})
            && mxl_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 = mxl_text_width($row->{plain})
            + mxl_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 + mxl_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})
            && mxl_text_width($separator_plain) + mxl_text_width($more_plain)
                > $row_width - mxl_text_width($row->{plain})) {
            $finish_row->();
            $separator_plain = '';
            $separator_text = '';
        }

        my $segment_start = mxl_text_width($row->{plain})
            + mxl_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 + mxl_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
        ? ($server->{chatnet} || $server->{tag} || '')
        : 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};
    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};
    pmu_redraw_statusbar();
    return 1;
}

sub pmu_clear_active_query {
    my ($window, $item) = @_;
    $window = Irssi::active_win() unless defined $window;
    return unless defined $window;

    my $active_window = Irssi::active_win();
    return unless defined $active_window;
    return unless defined $window->{refnum} && defined $active_window->{refnum};
    return unless $window->{refnum} == $active_window->{refnum};

    $item = $window->{active} unless defined $item;
    pmu_clear_query_item($item);
}

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

    $mxl_pm_pulse_on = 1;

    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);
}

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(); };

    pmu_clear_query_item($query);
    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);

    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};
            pmu_redraw_statusbar();
        }
        return;
    }

    # Incoming PMs never steal focus. They remain in the background and make
    # their window number pulse until the user explicitly enters the QUERY window.
    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_add_once(100, 'pmu_redraw_statusbar', '');
}

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

    # /MSG can accept a comma-separated target list. Focus the first nickname,
    # but never treat a channel target as a private conversation.
    for my $nick (split /,/, $target) {
        $nick =~ s/^\s+|\s+$//g;
        next if $nick eq '';
        next if eval { $server->ischannel($nick) };

        pmu_schedule_query_focus($server, $nick);
        last;
    }
}

sub pmu_signal_window_changed {
    my ($new_window, $old_window) = @_;
    pmu_clear_active_query($new_window, undef);
    pmu_remember_active_query($new_window, undef);
    Irssi::statusbar_items_redraw('mxl_clock');
}

sub pmu_signal_window_item_changed {
    my ($window, $item) = @_;
    pmu_clear_active_query($window, $item);
    pmu_remember_active_query($window, $item);
    Irssi::statusbar_items_redraw('mxl_clock');
}


sub pmu_signal_query_destroyed {
    my ($query) = @_;
    pmu_clear_query_item($query);
    pmu_forget_query_item($query);
}

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

    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);
    if (exists $unread{$old_key}) {
        my $entry = delete $unread{$old_key};
        my $new_key = pmu_unread_key($server, $new_nick);
        $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 = ();
        pmu_redraw_statusbar();
        Irssi::print("pm_unread: cleared $count unread conversation(s).");
        return;
    }

    my $wanted = pmu_normalise_nick($data);
    my $removed = 0;

    for my $key (keys %unread) {
        next unless pmu_normalise_nick($unread{$key}->{nick}) eq $wanted;
        delete $unread{$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: wybierz najpierw siec dla /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 {
    # Unread PM entries are rendered beside their network headers. Remove every legacy standalone item.
    Irssi::command('^STATUSBAR REMOVEITEM pm_unread window');
    Irssi::command('^STATUSBAR REMOVEITEM pm_unread pm_unread_bar');
    Irssi::command('^STATUSBAR MODIFY -disable pm_unread_bar');

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

sub pmu_module_unload {
    Irssi::timeout_remove($pmu_gc_timer) if $pmu_gc_timer;
    undef $pmu_gc_timer;

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

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

    # The standard window bar is restored centrally from its pre-load snapshot.
    # Avoid removing an item which may already be absent here.
    Irssi::command('^STATUSBAR MODIFY -disable pm_unread_bar');
}

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 item changed', 'pmu_signal_window_item_changed');
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
# ============================================================================
{
# -----------------------------------------------------------------------------
# USTAWIENIA: zwykle prywatne wiadomosci
# -----------------------------------------------------------------------------

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

# 4 roznych nowych nadawcow w 1 sekundzie uruchamia blokade MASS PRV.
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);

# Jeden nick wysylajacy wiele linii.
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');

# Ochrona przed wolnym otwieraniem QUERY ponizej progu MASS.
Irssi::settings_add_int ('prv_guard', 'prv_guard_max_queries', 40);

# Zaufane maski, np.:
# /set prv_guard_allow_masks BR!*@* mh!*@trusted.host
Irssi::settings_add_str ('prv_guard', 'prv_guard_allow_masks', '');

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

# -----------------------------------------------------------------------------
# USTAWIENIA: CTCP
# -----------------------------------------------------------------------------

# Tryby:
#   off    - brak osobnego filtra CTCP; zwykly MASS PRV nadal dziala
#   limit  - CTCP jest dozwolone do limitow ponizej
#   strict - domyslnie: CTCP od nowego nicka bez QUERY jest blokowane od razu;
#            CTCP w istniejacych rozmowach jest limitowane
#   block  - blokuje wszystkie CTCP poza zaufanymi maskami
#
# Prywatne /ME (CTCP ACTION) nie jest traktowane jako techniczne CTCP.
# Przechodzi przez zwykla ochrone MASS PRV.
Irssi::settings_add_str ('prv_guard', 'prv_guard_ctcp_mode', 'strict');

# Limit technicznych CTCP od jednego nadawcy.
# Pierwsze 3 w 10 sekund przechodza; czwarte uruchamia blokade nadawcy.
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');

# 4 roznych nadawcow CTCP w 1 sekundzie uruchamia globalna blokade CTCP.
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 jest przesylane jako CTCP DCC. Przy ON nowe DCC bez istniejacego QUERY
# jest blokowane takze w trybie limit.
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');

# -----------------------------------------------------------------------------
# STAN: zwykle PRV
# -----------------------------------------------------------------------------

my %burst_events;          # tag => [{time,nick,address,sender}]
my %mass_until;            # tag => timestamp
my %mass_blocked;          # tag => liczba zatrzymanych wiadomosci
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

# Rozmowa jest zaufana dopiero po tym, jak uzytkownik sam wysle do niej PW
# podczas pracy tej wersji skryptu. Samo istnienie QUERY nie daje zaufania.
my %trusted_queries;       # lc(tag)\0lc(nick) => 1

# -----------------------------------------------------------------------------
# STAN: CTCP
# -----------------------------------------------------------------------------

my %ctcp_events;               # tag => [{time,nick,address,sender,command,blocked}]
my %ctcp_mass_until;           # tag => timestamp
my %ctcp_mass_blocked;         # tag => liczba zablokowanych CTCP podczas 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

# -----------------------------------------------------------------------------
# STAN: purge
# -----------------------------------------------------------------------------

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

# -----------------------------------------------------------------------------
# FUNKCJE POMOCNICZE
# -----------------------------------------------------------------------------

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

sub guard_sender_id {
    my ($nick, $address) = @_;
    $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:' . lc($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, ''));
    my $new_state = guard_state_key($tag, guard_sender_id($new_nick, ''));
    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" . lc($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 do statusu kanalu, np. @#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;

    # Standardowo: "target :tekst". Obslugujemy tez brak dwukropka.
    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 : 'flood PRV',
    });

    for my $nick (@$nicks) {
        next unless defined($nick) && length($nick);
        next if eval { $server->ischannel($nick) };
        $pending->{nicks}->{lc($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 = "automatyczne sprzatanie po "
            . ($pending->{reason} || 'floodzie PRV')
            . ": zamknieto $closed nowych niezaufanych QUERY";
        $message .= "; usunieto $cleared pozostalych alertow PM" if $cleared;
        $message .= ". Istniejace i zaufane rozmowy pozostawiono.";
        guard_guard_print($message, $server);
    }
}

sub guard_new_attack_query_nicks {
    my ($events) = @_;
    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{lc($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 $sender = guard_sender_id($nick, $address);
    $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 =
        "blokada MASS PRV na $tag zakonczona; zatrzymano $blocked " .
        "wiadomosci od co najmniej $unique nadawcow";
    $summary .= " (limit ewidencji osiagniety; $overflow dalszych zdarzen)"
        if $overflow;
    $summary .= "; przyklady: $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);
    guard_schedule_flood_query_cleanup(
        $server,
        \@attack_queries,
        'wykrytym MASS PRV',
    ) 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'
    )
        ? ' Nowe niezaufane QUERY utworzone przez ten flood sa automatycznie zamykane;'
        : ' Istniejace okna QUERY pozostawiono;';

    guard_guard_print(
        "wykryto $reason na $tag." . $cleanup_text .
        " nowy niezaufany ruch prywatny jest blokowany przez " .
        int($block_time) . " s od aktywacji. Istniejace i zaufane rozmowy oraz allowlista pozostaja dostepne.",
        $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 ],
            'floodzie PRV od jednego nadawcy',
        );
    }

    if (($sender_notice_until{$state} || 0) <= $now) {
        my $mask = ($nick || '?') . '!' . ($address || '?');
        guard_guard_print(
            "szybki flood PRV od $mask na $tag: blokada nadawcy na " .
            int($block_time) . " s; nowe QUERY utworzone przez ten flood jest automatycznie zamykane, a istniejace lub zaufane pozostaje.",
            $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"
        . lc($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 $sender = guard_sender_id($nick, $address);
    $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 =
        "blokada CTCP na $tag zakonczona; zatrzymano $blocked pakietow " .
        "CTCP od co najmniej $unique nadawcow";
    $summary .= " (limit ewidencji osiagniety; $overflow dalszych zdarzen)"
        if $overflow;
    $summary .= "; przyklady: $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}
            );
        }
    } else {
        guard_remember_ctcp_mass_block($tag, $nick, $address, $command);
    }

    $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(
        "wykryto MASS CTCP: $limit roznych nadawcow/$window s na $tag. " .
        "Techniczne CTCP i odpowiedzi CTCP sa blokowane przez " .
        int($block_time) . " s od aktywacji; kolejne proby nie przedluzaja blokady.",
        $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(
            "flood CTCP $command od $mask na $tag: blokada nadawcy na " .
            int($block_time) . ' s.',
            $server,
        );
        $ctcp_sender_notice_until{$state} = $now + $block_time;
    }
}

# Zwraca 1, gdy CTCP ma zostac zatrzymane.
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);
    my $state = guard_state_key($tag, $sender);
    my $trusted = guard_query_is_trusted($server, $nick);

    my $events = guard_prune_ctcp_events($tag, $now);
    push @$events, {
        time    => $now,
        nick    => $nick || '',
        address => $address || '',
        sender  => $sender,
        command => $command,
    };

    my %unique = map { $_->{sender} => 1 } @$events;
    my $mass_limit = Irssi::settings_get_int('prv_guard_ctcp_mass_limit');

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

    if ($mass_limit > 0 && scalar(keys %unique) >= $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);
    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,
                    "twardy limit $max_queries otwartych QUERY", $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) . " niezaufanych nadawcow w $window s",
                $events
            );
            return 1;
        }
    }

    return 0;
}

# -----------------------------------------------------------------------------
# SUROWE PRIVMSG: CTCP + zwykle PRV
# -----------------------------------------------------------------------------

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();
    }
}

# -----------------------------------------------------------------------------
# SUROWE NOTICE: odpowiedzi CTCP
# -----------------------------------------------------------------------------

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("awaryjne czyszczenie zakonczone; zamknieto $closed QUERY.");
    }
}

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('brak otwartych QUERY do zamkniecia.');
        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("awaryjne zamykanie $total QUERY partiami...");
}

# -----------------------------------------------------------------------------
# OKRESOWE CZYSZCZENIE STANU
# -----------------------------------------------------------------------------

# 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 STANU
# -----------------------------------------------------------------------------

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    = ();
}

# -----------------------------------------------------------------------------
# KOMENDY
# -----------------------------------------------------------------------------

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('ochrona wlaczona.');
        return;
    }

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

    if ($cmd eq 'unlock') {
        guard_clear_guard_state();
        guard_guard_print('blokady i liczniki zostaly wyzerowane.');
        return;
    }

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

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

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

        Irssi::settings_set_str('prv_guard_ctcp_mode', $arg);
        guard_clear_guard_state();
        guard_guard_print("tryb ochrony CTCP ustawiony na: $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(
            "wersja=$VERSION; status=$enabled; PRV=$mass_limit nowych/$mass_window s; " .
            "jeden nick=$sender_limit/$sender_window s; blokada PRV=$mass_block s; " .
            "max QUERY=$max_queries; auto-close flood QUERY=$auto_close; zaufane QUERY=" . scalar(keys %trusted_queries) . "."
        );
        guard_guard_print(
            "CTCP=" . guard_ctcp_mode() . "; jeden nick=$ctcp_sender_limit/$ctcp_sender_window s; " .
            "MASS=$ctcp_mass_limit roznych/$ctcp_mass_window s; blokada=$ctcp_block s; " .
            "PING/VERSION=" .
            (Irssi::settings_get_bool('prv_guard_ctcp_allow_ping_version') ? 'ON' : 'OFF') .
            "; ACTION=/ME przepuszczane przez filtr PRV."
        );

        for my $tag (sort keys %mass_until) {
            my $left = int($mass_until{$tag} - time());
            $left = 0 if $left < 0;
            guard_guard_print("$tag: MASS PRV aktywny jeszcze co najmniej $left 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 aktywny jeszcze co najmniej $left s.");
        }
        return;
    }

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

# -----------------------------------------------------------------------------
# REJESTRACJA
# -----------------------------------------------------------------------------

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 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);

# Kazda rozmowa staje sie zaufana dopiero po wyslaniu przez Ciebie PW.
$guard_gc_timer = Irssi::timeout_add(60_000, \&guard_gc_state, undef);

sub guard_module_unload {
    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';

# Zwraca liczbę aktualnie otwartych okien prywatnych QUERY.
sub prvs_query_count {
    my @queries = Irssi::queries();
    return scalar @queries;
}

# Rysowanie elementu statusbara.
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[%WAct PRV%R] ' . $shown_count . '%W %0%W';

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

# Odświeżenie statusbara po utworzeniu lub zamknięciu PRV.
sub prvs_refresh_prv_status {
    Irssi::statusbar_items_redraw($ITEM);
    Irssi::statusbar_items_redraw('mxl_clock');
}

# Rejestracja elementu statusbara.
Irssi::statusbar_item_register(
    $ITEM,
    0,
    'prvs_sb_prv_status'
);

# Nowe okno PRV zwiększa licznik.
Irssi::signal_add(
    'query created',
    'prvs_refresh_prv_status'
);

# Zamknięcie PRV przez /wc zmniejsza licznik.
Irssi::signal_add(
    'query destroyed',
    'prvs_refresh_prv_status'
);

# Act PRV 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 {
    return;
}
}

# ============================================================================
# network_statusbars.pl
# ============================================================================
{
my $SCRIPT_VERSION   = '2.1.1';
my $MAX_BARS         = 64;
my $MAX_CHANNELS_ROW_NORMAL  = 8;
my $MAX_CHANNELS_ROW_COMPACT = 3;
my $BASE_POSITION    = -100;
my $OPER_WHO_TIMEOUT = 20_000;

my @slot_text         = ('');
my @slot_plain_length = (0);
my @slot_hitboxes     = ([]);

# 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;

# Bound geometry accepted from the PTY. Besides avoiding pathological memory
# use, this keeps the dashboard inside Irssi's own 4096-cell statusbar limit.
my $MXL_MAX_TERMINAL_COLUMNS = 4096;
my $MXL_MAX_TERMINAL_ROWS    = 2048;
my $MXL_MAX_MATRIX_COLUMNS   = 1024;
my $MXL_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 %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;
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
# dashboard 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. The owned tags are also mirrored to
# an Irssi setting so a normal /script reload cannot turn AUTOAWAY into a state
# which looks manual and therefore can no longer be cleared by channel activity.
my %auto_away_servers;
my $auto_away_timer_tag;
my $last_channel_activity_at = time();

# Number of PM entries currently expanded after Act: for each network.
# The initial value is one configured batch (normally five); clicking +N adds
# another batch below the existing rows instead of replacing them.
my %pm_visible_by_network;
my $refresh_timer;
my $force_layout = 1;
my $active_bar_count = 0;
my $last_seen_width = 0;
my $row_limit_warned = 0;

# 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;
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;
my $last_layout_columns = 1;

# Native left-hand MXL split retained as the default layout.
my $SIDEBAR_NAME = 'MXL-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 = '';
my $netbar_sidebar_buffer_sentinel;
my @netbar_sidebar_buffer_lines;
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_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;
my $netbar_sidebar_theme_applied_refnum = 0;
my $netbar_sidebar_chrome_ready = 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;

sub netbar_sidebar_mode {
    my $mode = lc(Irssi::settings_get_str('network_statusbars_layout') // '');
    return $mode eq 'sidebar' ? 1 : 0;
}

# 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 {
    return unless netbar_sidebar_mode();

    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 {
    undef $netbar_sidebar_buffer_sentinel;
    @netbar_sidebar_buffer_lines = ();
    @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 | $MXL_O_NOFOLLOW;
    my ($fh, $path, $name);
    for (1 .. 8) {
        $name = 'mxl_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);

    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;
    }

    $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);
        unlink $netbar_sidebar_theme_path
            if @current
                && !-l _ && -f _ && $current[4] == $<
                && $current[0] == $netbar_sidebar_theme_device
                && $current[1] == $netbar_sidebar_theme_inode;
    }

    $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 MXL-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 $netbar_sidebar_chrome_ready;

    # Remove the ordinary active/inactive split bars. The sidebar then uses its
    # complete height, while the conversation split receives only the custom
    # topic, Act spacing/rows and input prompt.
    for my $bar (qw(window window_inact)) {
        for my $item (qw(
            barstart barend time user window window_empty lag act more
            mxl_dark_fill mxl_clock pm_unread prv_status clients_activity
        )) {
            Irssi::command('^statusbar removeitem ' . $item . ' ' . $bar);
        }
        Irssi::command('^statusbar modify -disable ' . $bar);
    }

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

    # Topic only above the active conversation split, never above the sidebar.
    my $topic_bar = 'mxl_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 mxl_sidebar_topic mxl_dark_fill
    )) {
        Irssi::command('^statusbar removeitem ' . $item . ' ' . $topic_bar);
    }
    Irssi::command(
        '^statusbar additem -alignment left -priority 1000 '
        . 'mxl_sidebar_topic ' . $topic_bar
    );
    Irssi::command(
        '^statusbar additem -alignment left -after mxl_sidebar_topic '
        . '-priority "-1000" mxl_dark_fill ' . $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 = 'mxl_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
        mxl_sidebar_topic mxl_dark_fill mxl_clock pm_unread prv_status
        clients_activity
    )) {
        Irssi::command(
            '^statusbar removeitem ' . $item . ' ' . $topic_gap_bar
        );
    }
    Irssi::command(
        '^statusbar additem -alignment left -priority 1000 '
        . 'mxl_dark_fill ' . $topic_gap_bar
    );
    Irssi::command(
        '^statusbar modify -nodisable -type window -placement top '
        . '-position 1 -visible active ' . $topic_gap_bar
    );

    # Network/IN/OUT/NET now occupies the title row of the left sidebar. Remove
    # the former right-side statistics row, including one left behind by a hot
    # reload of an older script version. The conversation side keeps a blank
    # separator, two fixed Act rows and the input prompt.
    my $stats_bar = 'mxl_sidebar_stats_bar';
    for my $item (qw(
        barstart barend time user window window_empty lag act more
        mxl_sidebar_clock_pm mxl_sidebar_act_overflow mxl_dark_fill clients_activity
        mxl_clock mxl_dashboard pm_unread prv_status
    )) {
        Irssi::command('^statusbar removeitem ' . $item . ' ' . $stats_bar);
    }
    Irssi::command('^statusbar modify -disable ' . $stats_bar);

    # One permanently blank line above the clock/Act row.
    my $act_gap_bar = 'mxl_sidebar_act_gap_bar';
    Irssi::command(
        '^statusbar add -disable -type window -placement bottom '
        . '-position 97 -visible active ' . $act_gap_bar
    );
    for my $item (qw(
        barstart barend time user window window_empty lag act more
        mxl_sidebar_clock_pm mxl_sidebar_act_overflow mxl_dark_fill
        clients_activity mxl_clock mxl_dashboard pm_unread prv_status
    )) {
        Irssi::command('^statusbar removeitem ' . $item . ' ' . $act_gap_bar);
    }
    Irssi::command(
        '^statusbar additem -alignment left -priority 1000 '
        . 'mxl_dark_fill ' . $act_gap_bar
    );
    Irssi::command(
        '^statusbar modify -nodisable -type window -placement bottom '
        . '-position 97 -visible active ' . $act_gap_bar
    );

    my $info_bar = 'mxl_sidebar_info_bar';
    Irssi::command(
        '^statusbar add -disable -type window -placement bottom '
        . '-position 98 -visible active ' . $info_bar
    );
    for my $item (qw(
        barstart barend time user window window_empty lag act more
        mxl_sidebar_clock_pm mxl_sidebar_act_overflow mxl_dark_fill clients_activity
        mxl_clock mxl_dashboard pm_unread prv_status
    )) {
        Irssi::command('^statusbar removeitem ' . $item . ' ' . $info_bar);
    }
    Irssi::command(
        '^statusbar additem -alignment left -priority 1000 '
        . 'mxl_sidebar_clock_pm ' . $info_bar
    );
    Irssi::command(
        '^statusbar additem -alignment left -after mxl_sidebar_clock_pm '
        . '-priority "-1000" mxl_dark_fill ' . $info_bar
    );
    Irssi::command(
        '^statusbar modify -nodisable -type window -placement bottom '
        . '-position 98 -visible active ' . $info_bar
    );

    # Fixed continuation row between Act and the input prompt. It remains blank
    # until complete nr/nick labels no longer fit on the first row.
    my $overflow_bar = 'mxl_sidebar_act_overflow_bar';
    Irssi::command(
        '^statusbar add -disable -type window -placement bottom '
        . '-position 99 -visible active ' . $overflow_bar
    );
    for my $item (qw(
        barstart barend time user window window_empty lag act more
        mxl_sidebar_clock_pm mxl_sidebar_act_overflow mxl_dark_fill
        clients_activity mxl_clock mxl_dashboard pm_unread prv_status
    )) {
        Irssi::command('^statusbar removeitem ' . $item . ' ' . $overflow_bar);
    }
    Irssi::command(
        '^statusbar additem -alignment left -priority 1000 '
        . 'mxl_sidebar_act_overflow ' . $overflow_bar
    );
    Irssi::command(
        '^statusbar additem -alignment left -after mxl_sidebar_act_overflow '
        . '-priority "-1000" mxl_dark_fill ' . $overflow_bar
    );
    Irssi::command(
        '^statusbar modify -nodisable -type window -placement bottom '
        . '-position 99 -visible active ' . $overflow_bar
    );

    # The real input line is also a window bar 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'
    );
    mxl_apply_dynamic_prompt();

    Irssi::statusbar_items_redraw('mxl_sidebar_topic');
    Irssi::statusbar_items_redraw('mxl_sidebar_clock_pm');
    Irssi::statusbar_items_redraw('mxl_sidebar_act_overflow');
    Irssi::statusbar_items_redraw('clients_activity');
    Irssi::statusbar_items_redraw('mxl_prompt_context');
    $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;
    Irssi::command('^statusbar modify -disable mxl_sidebar_topic_bar');
    Irssi::command('^statusbar modify -disable mxl_sidebar_topic_gap_bar');
    Irssi::command('^statusbar modify -disable mxl_sidebar_act_gap_bar');
    Irssi::command('^statusbar modify -disable mxl_sidebar_info_bar');
    Irssi::command('^statusbar modify -disable mxl_sidebar_act_overflow_bar');
    Irssi::command('^statusbar modify -disable mxl_sidebar_stats_bar');
    Irssi::command(
        '^statusbar modify -nodisable -type root -placement bottom '
        . '-position 100 -visible always prompt'
    );
    Irssi::command('^statusbar modify -nodisable topic');
    Irssi::command('^statusbar modify -nodisable window');
    Irssi::command('^statusbar modify -nodisable window_inact');
}

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;
    my $saved_pulse = $mxl_pm_pulse_on;
    $netbar_width_override = 200;
    $mxl_pm_pulse_on = 1; # sidebar is deliberately non-animated
    my @rows = netbar_build_layout_rows();
    $mxl_pm_pulse_on = $saved_pulse;
    $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 =~ /\0channels\0/) {
            $longest_channel = $length if $length > $longest_channel;
        }
        elsif ($key =~ /\0(?:header|status)$/) {
            $longest_header = $length if $length > $longest_header;
        }
    }

    # 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 full Network/IN/OUT/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;
    return unless netbar_sidebar_mode();

    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 {
    return 0 unless netbar_sidebar_mode();

    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 unless netbar_sidebar_mode();
    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;
    return 0 unless netbar_sidebar_mode();

    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;
    return unless netbar_sidebar_mode();

    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;
    return unless netbar_sidebar_mode();

    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;
    return unless netbar_sidebar_mode();
    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 {
    my ($close_window) = @_;
    my $sidebar = netbar_sidebar_window();
    my $content = netbar_sidebar_content_window();

    $netbar_sidebar_layout_guard = 1;
    if ($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 => mxl_text_width($plain),
        mouse_kind   => 'overflow',
        mouse_direction => $direction eq '^' ? 'up' : 'down',
        mouse_x_start   => 0,
    };
}

sub netbar_sidebar_visible_rows {
    my ($rows_ref, $capacity) = @_;
    my @rows = @$rows_ref;
    return @rows if @rows <= $capacity;
    return () if $capacity <= 0;

    my $focus = 0;
    for my $index (0 .. $#rows) {
        if (($rows[$index]->{text} // '') =~ /%[BR]>/) {
            $focus = $index;
            last;
        }
    }

    my $data_slots = $capacity > 2 ? $capacity - 2 : $capacity;
    my $start = $focus - int($data_slots / 2);
    $start = 0 if $start < 0;
    $start = @rows - $data_slots if $start + $data_slots > @rows;
    $start = 0 if $start < 0;

    # Recalculate because an indicator is not needed at an edge.
    for (1 .. 3) {
        my $top_needed = $start > 0 ? 1 : 0;
        my $bottom_needed = ($start + $data_slots < @rows) ? 1 : 0;
        my $new_slots = $capacity - $top_needed - $bottom_needed;
        $new_slots = 1 if $new_slots < 1;
        last if $new_slots == $data_slots;
        $data_slots = $new_slots;
        $start = $focus - int($data_slots / 2);
        $start = 0 if $start < 0;
        $start = @rows - $data_slots if $start + $data_slots > @rows;
        $start = 0 if $start < 0;
    }

    my $end = $start + $data_slots - 1;
    $end = $#rows if $end > $#rows;

    my @visible;
    push @visible, netbar_sidebar_overflow_row('^', $start) if $start > 0;
    push @visible, @rows[$start .. $end];
    my $hidden_below = @rows - $end - 1;
    push @visible, netbar_sidebar_overflow_row('v', $hidden_below)
        if $hidden_below > 0;

    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 $padding = $inner_width - $plain_length;
    $padding = 0 if $padding < 0;

    return ($row->{text} // '%0%W')
        . (' ' x $padding)
        . '%0%k│%n';
}

sub netbar_sidebar_buffer_ready {
    my ($width, $height) = @_;
    return 0 unless $netbar_sidebar_buffer_sentinel;
    return 0 unless $netbar_sidebar_buffer_width == $width;
    return 0 unless $netbar_sidebar_buffer_height == $height;
    return 0 unless @netbar_sidebar_buffer_lines == $height;
    return 0 unless @netbar_sidebar_buffer_text == $height;
    return 1;
}

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 permanent off-screen anchor lets the first visible row be replaced
    # with Window::print_after(), exactly like every later row. With HEIGHT
    # visible rows following it, the anchor itself never appears on screen.
    $sidebar->print('%0%W', Irssi::MSGLEVEL_NEVER());
    my $previous = $view->get_lines();
    return 0 unless $previous;
    $netbar_sidebar_buffer_sentinel = $previous;

    for my $text (@$texts_ref) {
        $sidebar->print_after(
            $previous,
            Irssi::MSGLEVEL_NEVER(),
            $text,
        );
        my $inserted = $previous->next();
        return 0 unless $inserted;
        push @netbar_sidebar_buffer_lines, $inserted;
        push @netbar_sidebar_buffer_text, $text;
        $previous = $inserted;
    }

    $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;

    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;

        my $previous = $index == 0
            ? $netbar_sidebar_buffer_sentinel
            : $netbar_sidebar_buffer_lines[$index - 1];
        my $old_line = $netbar_sidebar_buffer_lines[$index];
        return 0 unless $previous && $old_line;

        # Replace only this row at the same buffer position. No CLEAR, no
        # scrolling and no temporary half-painted list are involved.
        $view->remove_line($old_line);
        $sidebar->print_after(
            $previous,
            Irssi::MSGLEVEL_NEVER(),
            $new_text,
        );
        my $inserted = $previous->next();
        return 0 unless $inserted;

        $netbar_sidebar_buffer_lines[$index] = $inserted;
        $netbar_sidebar_buffer_text[$index] = $new_text;
    }

    $view->redraw();
    return 1;
}

sub netbar_sidebar_render {
    return unless netbar_sidebar_mode();
    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;
    # Keep the real pulse state here. A direct mention of our nick makes only
    # that channel's window number alternate between %W and %K until opened.
    my @rows = netbar_build_layout_rows();
    $netbar_width_override = $previous_width_override;

    # Clock, PM summaries, the native Act list and MAP statistics now live
    # in a global root statusbar directly above the prompt. The sidebar uses its
    # complete vertical area exclusively for network/channel rows.
    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.
    Irssi::term_refresh_freeze();
    my $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 immediately in the same frozen frame.
            die "sidebar buffer recovery failed\n"
                unless netbar_sidebar_rebuild_buffer(
                    $sidebar,
                    $view,
                    \@screen_text,
                    $width,
                    $height,
                );
        }
        1;
    };
    my $render_error = $@;
    Irssi::term_refresh_thaw();

    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 {
    return if $netbar_sidebar_layout_guard;
    return unless netbar_sidebar_mode();

    # 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();
}


sub netbar_signal_window_destroyed {
    my ($window) = @_;
    return if $netbar_sidebar_shutting_down;
    return unless netbar_sidebar_mode();

    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;

    return ($plain, netbar_format_dynamic_text($plain));
}


sub netbar_bar_name {
    my ($slot) = @_;
    return sprintf('network_channels_%02d', $slot);
}

sub netbar_item_name {
    my ($slot) = @_;
    return sprintf('network_channels_item_%02d', $slot);
}

sub netbar_callback_name {
    my ($slot) = @_;
    return sprintf('network_channels_render_%02d', $slot);
}

sub netbar_plain_dynamic_text {
    my ($text) = @_;
    $text = mxl_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->{chatnet} || $server->{tag} || '';
}

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 make B's Status label pulse.
    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;
    $mxl_pm_pulse_on = 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" . lc($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/;
        $loaded{$key} = 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 length($key) && $muted_channels{$key} ? 1 : 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 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};
    }
}

sub netbar_clear_current_channel_activity {
    my $window = Irssi::active_win();
    return unless $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($nick);
    return $message =~
        /(?<![A-Za-z0-9_\-\[\]\\`^{}|])$quoted(?![A-Za-z0-9_\-\[\]\\`^{}|])/i
        ? 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;
}


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

    my $nick = $server->{nick} // '';
    return 0 unless length $nick;

    my $quoted = quotemeta($nick);
    return $message =~
        /\A\s*$quoted(?:\s*[:,]\s*|\s+)(?:.*)\z/is
        ? 1 : 0;
}

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) = @_;
    $nick = '' unless defined $nick;
    return lc($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;

    # 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, $directed) = @_;
    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) && lc($nick) eq lc($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);
    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;

    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}++;
        }

        # Auto-reply only to a nick that directly addressed us at the start of
        # the line; an incidental mention still appears in the local report.
        netbar_away_send_notice($server, $state, $sender) if $directed;
    }
}

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
            . ': wykryto masowy spam wzmiankami od co najmniej ' . $unique
            . ' unikalnych nickow'
            . ($identity_overflow ? ' (limit ewidencji osiagniety)' : '')
            . ' (' . $messages . ' wiadomosci) na '
            . $where
            . '; tresci nie zapisano, a automatyczne NOTICE zostaly wstrzymane.'
        );
        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 . ': wzmianki podczas nieobecnosci:'
    );

    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
            : ' (tresc wiadomosci nie byla przechowywana)';
        netbar_away_status_print(
            '[' . $clock . '] ' . $nick . ' na ' . $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 od ' . $nick . ' na '
            . $channels . ' (' . $count . ' wiadomosci ze wzmianka).'
        );
    }

    if (($state->{overflow_count} // 0) > 0) {
        netbar_away_status_print(
            '[AWAY] ' . $network . ': ochrona pamieci pominela '
            . int($state->{overflow_count})
            . ' nadmiarowych wzmianek; ich tresc nie zostala zachowana.'
        );
    }

    if (($state->{notices_suppressed} // 0) > 0) {
        netbar_away_status_print(
            '[AWAY] ' . $network . ': ochrona przed floodem wstrzymala '
            . int($state->{notices_suppressed})
            . ' automatycznych odpowiedzi NOTICE.'
        );
    }
}

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_auto_away_owned_key_is_safe {
    my ($key) = @_;
    return defined($key) && $key =~ /\A[A-Za-z0-9_.:-]{1,128}\z/ ? 1 : 0;
}

sub netbar_auto_away_owned_persist {
    my @keys = sort grep {
        netbar_auto_away_owned_key_is_safe($_)
    } keys %auto_away_servers;

    Irssi::settings_set_str(
        'network_statusbars_auto_away_owned_tags',
        join(',', @keys),
    );
}

sub netbar_auto_away_owned_restore {
    my $stored = Irssi::settings_get_str(
        'network_statusbars_auto_away_owned_tags'
    );
    $stored = '' unless defined $stored;

    for my $key (split /,/, $stored) {
        $key = lc($key // '');
        next unless netbar_auto_away_owned_key_is_safe($key);
        $auto_away_servers{$key} = time();
    }

    # A stored tag is valid only while the matching live connection is still
    # AWAY. This removes stale ownership after a reconnect, while keeping it
    # through an ordinary script reload on an already-AWAY connection.
    for my $server (netbar_away_connected_servers()) {
        my $key = netbar_away_session_key($server);
        next unless length($key) && exists $auto_away_servers{$key};
        delete $auto_away_servers{$key}
            unless netbar_away_server_is_active($server);
    }

    netbar_auto_away_owned_persist();
}

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

    my $key = netbar_away_session_key($server);
    return unless length($key) && exists $auto_away_servers{$key};
    return if netbar_away_server_is_active($server);

    delete $auto_away_servers{$key};
    netbar_auto_away_owned_persist();
}

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};
        }
        netbar_auto_away_owned_persist();
    }

    # 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};
        }
        netbar_auto_away_owned_persist();
    }

    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}
        ? $auto_away_servers{$key}
        : undef;
    if (length $key) {
        delete $auto_away_servers{$key};
        netbar_auto_away_owned_persist();
    }

    # 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;
    };
    if (!$ok && length($key) && defined($had_auto)) {
        $auto_away_servers{$key} = $had_auto;
        netbar_auto_away_owned_persist();
    }

    return $ok ? 1 : 0;
}

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] Brak polaczonych sieci.');
        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] Nie udalo sie wyslac komendy AWAY.');
        return;
    }

    my $network_list = join(', ', @networks);
    if ($mode eq 'off') {
        netbar_away_status_print(
            '[GAWAY] Wylaczono AWAY na ' . $sent . ' sieciach: '
            . $network_list
        );
    }
    else {
        netbar_away_status_print(
            '[GAWAY] Ustawiono AWAY na ' . $sent . ' sieciach: '
            . $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] Brak aktywnej polaczonej sieci.');
        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] Wylaczono AWAY na ' . $network . '.'
            );
        }
        else {
            netbar_away_status_print(
                '[AWAY] Nie udalo sie wylaczyc AWAY na ' . $network . '.'
            );
        }
        return;
    }

    if (netbar_away_send_set($server, $reason, 0)) {
        netbar_away_status_print(
            '[AWAY] Ustawiono AWAY na ' . $network . ' - '
            . netbar_away_status_text($reason)
        );
    }
    else {
        netbar_away_status_print(
            '[AWAY] Nie udalo sie ustawic AWAY na ' . $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) {
        netbar_away_status_print(
            '[AUTO-AWAY] Po godzinie bez aktywnosci ustawiono AWAY na: '
            . join(', ', @set)
            . '. Istniejace reczne AWAY pozostawiono bez zmian.'
        );
    }
}

sub netbar_auto_away_clear_owned {
    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) {
        netbar_away_status_print(
            '[AUTO-AWAY] Aktywnosc na kanale wylaczyla automatyczny AWAY na: '
            . join(', ', @cleared)
            . '. Reczne AWAY pozostalo bez zmian.'
        );
    }
}

sub netbar_auto_away_note_channel_activity {
    $last_channel_activity_at = time();
    netbar_auto_away_clear_owned();
}

sub netbar_auto_away_send_text_activity {
    my ($line, $server, $witem) = @_;
    return unless defined($line) && $line =~ /\S/;
    return unless $witem && netbar_item_kind($witem) eq 'CHANNEL';
    return unless $witem->{joined};

    my $item_server = $witem->{server};
    $server ||= $item_server;
    return unless $server && $server->{connected};
    if ($item_server) {
        my $server_tag = netbar_server_tag($server);
        my $item_tag = netbar_server_tag($item_server);
        return if length($server_tag) && length($item_tag)
            && lc($server_tag) ne lc($item_tag);
    }

    netbar_auto_away_note_channel_activity();
}

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();
}

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_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, 'Brak aktywnej sieci.') unless $server;
    return (undef, undef, 'Uzycie: /mute #kanal lub /unmute #kanal.')
        unless length $channel_name;

    my $channel = eval { $server->channel_find($channel_name) };
    return (undef, undef, 'Nie znaleziono dolaczonego kanalu ' . $channel_name
        . ' na aktualnej sieci.')
        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 ($muted_channels{$key}) {
        netbar_away_status_print('[MUTE] ' . $network . '/' . $name
            . ': kanal jest juz wyciszony.');
        return;
    }

    $muted_channels{$key} = 1;
    delete $message_activity{$key};
    delete $nick_attention{$key};
    netbar_muted_channels_save();
    netbar_schedule_refresh();
    netbar_away_status_print('[MUTE] ' . $network . '/' . $name
        . ': wyciszono powiadomienia; stan pozostanie do /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 (!$muted_channels{$key}) {
        netbar_away_status_print('[MUTE] ' . $network . '/' . $name
            . ': kanal nie jest wyciszony.');
        return;
    }

    delete $muted_channels{$key};
    delete $message_activity{$key};
    delete $nick_attention{$key};
    netbar_muted_channels_save();
    netbar_schedule_refresh();
    netbar_away_status_print('[MUTE] ' . $network . '/' . $name
        . ': wyciszenie wylaczone.');
}

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)) {
        if (exists $auto_away_servers{$key}) {
            delete $auto_away_servers{$key};
            netbar_auto_away_owned_persist();
        }
        my $state = delete $away_mention_sessions{$key};
        netbar_away_report_session($state) if $state;
    }

    netbar_schedule_refresh();
}

sub netbar_signal_channel_message {
    my ($server, $message, $nick, $address, $target) = @_;
    my $channel = netbar_find_message_channel($server, $target);
    return unless $channel && $channel->{joined};

    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 dashboard activity, nick pulse, AWAY NOTICE or AWAY report.
    if (netbar_channel_is_muted($server, $channel->{name})) {
        delete $message_activity{$key};
        delete $nick_attention{$key};
        netbar_schedule_refresh();
        return;
    }

    my $mentions_own_nick = netbar_message_mentions_own_nick($server, $message);
    if ($mentions_own_nick) {
        my $directed = netbar_message_directly_addresses_own_nick(
            $server, $message
        );
        netbar_away_record_mention(
            $server,
            $message,
            $nick,
            $channel->{name},
            $directed,
        );
    }

    if (netbar_channel_is_current($channel)) {
        delete $message_activity{$key};
        delete $nick_attention{$key};
    }
    else {
        if ($mentions_own_nick) {
            # A direct nick mention has its own stronger pulsing indication.
            # The window number pulses bright/dark 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,
            refnum => int($window->{refnum}),
            name   => $query->{name},
        };
    }

    @queries = sort {
        $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) = @_;
    if (netbar_sidebar_mode()) {
        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});

    if (netbar_sidebar_mode()) {
        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})
        && lc($active_item->{name}) eq lc($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;
    }

    netbar_remember_active_item();
    netbar_pm_reset_page_for_item($fresh_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;

    if (netbar_sidebar_mode()) {
        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_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;
    if (defined(&netbar_sidebar_mode) && netbar_sidebar_mode()) {
        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 = $MXL_MAX_MATRIX_COLUMNS
        if $columns > $MXL_MAX_MATRIX_COLUMNS;
    $rows = $MXL_MAX_MATRIX_ROWS
        if $rows > $MXL_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) {
        netbar_matrix_write("\e[0m\e[?25h");
        Irssi::command('^redraw') if $redraw;
    }
}

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');
    netbar_matrix_write("\e[0m\e[?25h");
}

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('mxl_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;
    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('mxl_matrix_transition_mode') // 'window');
    return $mode eq 'join' ? 'join' : 'window';
}

sub netbar_matrix_start_for_item {
    my ($target) = @_;
    # WINDOW mode is the original clickable-dashboard 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('mxl_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)
            && lc($current->{name}) eq lc($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_input_cursor_style {
    my $style = lc(
        Irssi::settings_get_str('mxl_matrix_cursor_style') // 'block'
    );
    return 'underline' if $style eq 'underline';
    return 'default' if $style eq 'default' || $style eq 'off';
    return 'block';
}

# DECSCUSR 1 requests a blinking block and DECSCUSR 3 a blinking underline.
# Terminals which do not implement DECSCUSR simply ignore the sequence, so the
# Matrix typing effect still works. Cursor colour remains terminal-controlled.
sub netbar_matrix_input_cursor_apply {
    return unless -t STDERR;
    my $enabled = Irssi::settings_get_bool('mxl_matrix_input_enabled') ? 1 : 0;
    if (!$enabled) {
        netbar_matrix_write("\e[0 q");
        return;
    }

    my $style = netbar_matrix_input_cursor_style();
    if ($style eq 'default') {
        netbar_matrix_write("\e[0 q");
        return;
    }

    my $shape = $style eq 'underline' ? 3 : 1;
    netbar_matrix_write("\e[" . $shape . " q");
}

sub netbar_matrix_input_cursor_restore {
    return unless -t STDERR;
    # Ps=0 asks the terminal to return to its configured/default cursor shape.
    netbar_matrix_write("\e[0 q");
}

sub netbar_matrix_input_paint_glyph {
    return unless -t STDERR;
    my $glyph = netbar_matrix_input_random_glyph();

    # The terminal cursor is already exactly where Irssi will insert the next
    # character. Paint the glyph on an explicit black background, restore the
    # cursor, then leave a known white-on-black rendition for Irssi. A bare
    # SGR reset here inherited some terminals' blue theme background and could
    # flash it through the blank row between Act and the prompt after a
    # channel/QUERY switch.
    netbar_matrix_write(
        "\e7\e[1;92;40m" . $glyph . "\e8\e[0;97;40m"
    );
    $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('MXL 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('mxl_matrix_input_enabled')
        || $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('mxl_matrix_input_flash_ms');
    $hold = 35 if !defined($hold) || $hold < 20;
    $hold = 120 if $hold > 120;

    # When the user types extremely quickly, shorten the hold slightly instead
    # of building visible input lag. Every key still gets at least two frames.
    $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;
    }

    # Change the visible Matrix glyph every frame, not just once. At the default
    # 35 ms this gives roughly three visibly different glyphs per typed letter.
    netbar_matrix_input_paint_glyph();
    $netbar_matrix_input_timer_tag = Irssi::timeout_add_once(
        10,
        '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;
    return unless Irssi::settings_get_bool('mxl_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_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('mxl_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 {
    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;
        }
        next if $arg =~ /^-/;
        $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('mxl_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('mxl_matrix_enabled')
            ? 'ON' : 'OFF';
        my $mode = uc(netbar_matrix_transition_mode());
        my $duration = Irssi::settings_get_int('mxl_matrix_duration_ms');
        my $input_state = Irssi::settings_get_bool('mxl_matrix_input_enabled')
            ? 'ON' : 'OFF';
        my $input_flash = Irssi::settings_get_int('mxl_matrix_input_flash_ms');
        my $cursor_style = uc(netbar_matrix_input_cursor_style());
        Irssi::print(
            'MXL Matrix: transition=' . $state . ', mode=' . $mode
            . ' (' . $duration . ' ms), startup=ON, input=' . $input_state
            . ' (instant glyph ' . $input_flash . ' ms), cursor='
            . $cursor_style . '.'
        );
        return;
    }

    if (lc($data) eq 'on') {
        Irssi::settings_set_bool('mxl_matrix_enabled', 1);
        Irssi::print(
            'MXL 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('mxl_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('MXL 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('mxl_matrix_transition_mode', $mode);
        Irssi::settings_set_bool('mxl_matrix_enabled', 1);
        netbar_matrix_cancel(1)
            if $netbar_matrix_running && length($netbar_matrix_target_name);
        Irssi::print(
            $mode eq 'window'
                ? 'MXL Matrix: WINDOW mode - animation when switching to an existing channel from the dashboard; /j and /join stay immediate. Typing effect unchanged.'
                : 'MXL Matrix: JOIN mode - dashboard 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('MXL Matrix: duration must be between 180 and 900 ms.');
            return;
        }
        Irssi::settings_set_int('mxl_matrix_duration_ms', $duration);
        Irssi::print('MXL Matrix: transition duration set to ' . $duration . ' ms.');
        return;
    }

    if ($data =~ /^cursor(?:\s+(block|square|underline|under|_|off|default|status))?$/i) {
        my $action = defined($1) ? lc($1) : '';
        if ($action eq 'status') {
            Irssi::print(
                'MXL Matrix cursor: '
                . uc(netbar_matrix_input_cursor_style()) . '.'
            );
            return;
        }

        my $style;
        if ($action eq '') {
            $style = netbar_matrix_input_cursor_style() eq 'block'
                ? 'underline'
                : 'block';
        }
        elsif ($action eq 'underline' || $action eq 'under' || $action eq '_') {
            $style = 'underline';
        }
        elsif ($action eq 'off' || $action eq 'default') {
            $style = 'default';
        }
        else {
            $style = 'block';
        }

        Irssi::settings_set_str('mxl_matrix_cursor_style', $style);
        netbar_matrix_input_cursor_apply();
        Irssi::print(
            'MXL Matrix cursor: '
            . ($style eq 'underline'
                ? 'blinking underline (_).'
                : $style eq 'default'
                    ? 'terminal default (script cursor override off).'
                    : 'blinking block.')
        );
        return;
    }

    if ($data =~ /^input\s+(on|off)$/i) {
        my $enabled = lc($1) eq 'on' ? 1 : 0;
        Irssi::settings_set_bool('mxl_matrix_input_enabled', $enabled);
        netbar_matrix_input_cancel() unless $enabled;
        netbar_matrix_input_cursor_apply();
        Irssi::print('MXL Matrix input glyph effect: ' . ($enabled ? 'enabled.' : 'disabled.'));
        return;
    }

    if ($data =~ /^input\s+test$/i) {
        netbar_matrix_input_manual_test();
        Irssi::print('MXL 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('MXL Matrix input duration must be between 20 and 120 ms.');
            return;
        }
        Irssi::settings_set_int('mxl_matrix_input_flash_ms', $flash);
        Irssi::print('MXL Matrix input glyph duration set to ' . $flash . ' ms.');
        return;
    }

    Irssi::print('Usage: /matrix [on|off|window|join|status|duration 180-900|cursor [block|underline|off|status]|input on|input off|input test|input duration 20-120]');
}

# Keep an expanded PM block stable when the user enters a QUERY. The active
# QUERY is moved to the front by pmu_network_stack_entries(), so even keyboard
# navigation to an entry hidden under +N reveals it without collapsing rows
# which the user already expanded.
sub netbar_pm_reset_page_for_item {
    my ($item) = @_;
    return unless netbar_item_kind($item) eq 'QUERY';
    return unless $item->{server};

    my $network = netbar_network_name_for_server($item->{server});
    return unless length($network);

    my $batch = Irssi::settings_get_int('network_statusbars_pm_rows');
    $batch = 6 if !defined($batch) || $batch < 1;
    $batch = 20 if $batch > 20;

    my $key = lc($network);
    my $visible = int($pm_visible_by_network{$key} // $batch);
    $visible = $batch if $visible < $batch;
    $pm_visible_by_network{$key} = $visible;
}

# Left click on +N expands another batch below the PMs already displayed.
# Right click contracts one batch while never going below the initial batch.
sub netbar_pm_adjust_visible {
    my ($network, $direction) = @_;
    return unless defined($network) && length($network);
    return unless defined(&pmu_network_stack_entries);

    my @entries = pmu_network_stack_entries($network);
    my $total = scalar @entries;
    return unless $total > 0;

    my $batch = Irssi::settings_get_int('network_statusbars_pm_rows');
    $batch = 6 if !defined($batch) || $batch < 1;
    $batch = 20 if $batch > 20;

    my $key = lc($network);
    my $visible = int($pm_visible_by_network{$key} // $batch);
    $visible = $batch if $visible < $batch;

    if (defined($direction) && $direction < 0) {
        $visible -= $batch;
        $visible = $batch if $visible < $batch;
    }
    else {
        $visible += $batch;
        $visible = $total if $visible > $total;
    }

    $pm_visible_by_network{$key} = $visible;
    netbar_schedule_refresh();
}

# -------------------------------------------------------------------------
# Clickable network dashboard
# -------------------------------------------------------------------------

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 = $MXL_MAX_TERMINAL_COLUMNS
        if $columns > $MXL_MAX_TERMINAL_COLUMNS;
    $rows = $MXL_MAX_TERMINAL_ROWS
        if $rows > $MXL_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 {
    $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('mxl_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 {
    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('mxl_mouse_enabled')) {
        netbar_mouse_tracking_enable();
    }
    else {
        netbar_mouse_tracking_disable();
    }
}

sub netbar_mouse_command_xterm {
    return unless Irssi::settings_get_bool('mxl_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('mxl_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;

    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 dashboard 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 netbar_sidebar_mode();
    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} // '',
        };
    }
    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 'overflow') {
        return {
            type      => 'SCROLL',
            direction => $target->{mouse_direction} // '',
        };
    }
    return undef;
}

# The Act rows belong to the active conversation split, not to the technical
# left pane. Resolve their absolute terminal cells separately: the two fixed
# bottom bars sit directly above the prompt, and the content split begins at
# the sidebar width used by the Matrix clipping code as well.
sub mxl_sidebar_pm_hitbox_at {
    my ($x, $y) = @_;
    return undef unless netbar_sidebar_mode();
    return undef unless defined($x) && defined($y) && $x >= 0 && $y >= 0;

    my $height = netbar_mouse_sync_layout_for_click();
    return undef unless $height > 2;

    my $row_index;
    if (int($y) == $height - 3) {
        $row_index = 0;
    }
    elsif (int($y) == $height - 2) {
        $row_index = 1;
    }
    else {
        return undef;
    }

    my $sidebar = netbar_sidebar_window();
    my $content = netbar_sidebar_content_window();
    return undef unless $sidebar && $content;

    my $available = int($content->{width} // 0);
    my $sidebar_left = int($sidebar->{width} // 0);
    $available = $netbar_mouse_screen_width - $sidebar_left
        if $available < 1;
    return undef if $available < 1;

    my (undef, undef, undef, undef, $first, $second) =
        mxl_sidebar_activity_rows($available);
    my $segments = $row_index == 0 ? $first : $second;
    return undef unless ref($segments) eq 'ARRAY';

    # Depending on the Irssi build, window->{width} may exclude the one-cell
    # split divider. Try the right-pane origin derived from terminal geometry
    # first, then the sidebar width used by older builds.
    my @origins;
    push @origins, $netbar_mouse_screen_width - $available
        if $netbar_mouse_screen_width >= $available;
    push @origins, $sidebar_left;
    my %seen_origin;
    for my $origin (grep { !$seen_origin{$_}++ } @origins) {
        my $relative_x = int($x) - int($origin);
        next if $relative_x < 0 || $relative_x >= $available;
        for my $segment (@$segments) {
            next unless $segment;
            my $start = int($segment->{x_start} // -1);
            my $end = int($segment->{x_end} // -1);
            return $segment
                if $relative_x >= $start && $relative_x <= $end;
        }
    }
    return undef;
}

sub netbar_mouse_hitbox_at {
    my ($x, $y) = @_;
    return undef unless defined($x) && defined($y);
    if (netbar_sidebar_mode()) {
        my $pm_hitbox = mxl_sidebar_pm_hitbox_at($x, $y);
        return $pm_hitbox if $pm_hitbox;
        return netbar_mouse_sidebar_hitbox_at($x, $y);
    }
    return undef unless $active_bar_count > 0;

    my $height = netbar_mouse_sync_layout_for_click();
    return undef unless $height > 0;

    # Number of terminal rows below the custom network dashboard. In this
    # layout they are normally the ordinary window bar and the input/prompt.
    my $offset = Irssi::settings_get_int('mxl_mouse_offset');
    $offset = 0 if $offset < 0;

    my $line = int($y) - $height + $active_bar_count + $offset;
    return undef if $line < 0 || $line >= $active_bar_count;

    my $slot = $line + 1;
    my $hitboxes = $slot_hitboxes[$slot];
    return undef unless ref($hitboxes) eq 'ARRAY';

    for my $hitbox (@$hitboxes) {
        next unless $hitbox;
        my $start = int($hitbox->{x_start} // -1);
        my $end   = int($hitbox->{x_end} // -1);
        return $hitbox if int($x) >= $start && int($x) <= $end;
    }
    return undef;
}

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 'SCROLL') {
        my $direction = $hitbox->{direction} // '';
        if ($direction eq 'up') {
            netbar_command_nav_up();
        }
        elsif ($direction eq 'down') {
            netbar_command_nav_down();
        }
        return;
    }

    if (($hitbox->{type} // '') eq 'PM_MORE') {
        if ($hitbox->{sidebar_activity}) {
            mxl_sidebar_pm_adjust_page(1);
            return;
        }
        netbar_pm_adjust_visible($hitbox->{network}, 1);
        return;
    }

    if (($hitbox->{type} // '') eq 'PM_PREV') {
        mxl_sidebar_pm_adjust_page(-1);
        return;
    }

    if (($hitbox->{type} // '') eq 'PM_NEXT') {
        mxl_sidebar_pm_adjust_page(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;

    if (($hitbox->{type} // '') eq 'PM_MORE') {
        if ($hitbox->{sidebar_activity}) {
            mxl_sidebar_pm_adjust_page(-1);
            return;
        }
        netbar_pm_adjust_visible($hitbox->{network}, -1);
        return;
    }

    return unless ($hitbox->{type} // '') eq 'CHANNEL'
        || ($hitbox->{type} // '') eq 'QUERY';

    if (!$hitbox->{immediate_close}
        && Irssi::settings_get_bool('mxl_mouse_confirm_close')) {
        my $key = join("\0",
            $hitbox->{type} // '',
            $hitbox->{tag} // '',
            $hitbox->{name} // '',
        );
        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(
                'MXL: powtorz prawy klik w ciagu 1,5 s, aby zamknac wybrane okno.',
                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_event {
    my ($button, $x, $y, $old_button, $old_x, $old_y) = @_;

    # The Matrix overlay temporarily hides the dashboard. 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 (netbar_sidebar_mode()
        && defined($button)
        && (int($button) == 64 || int($button) == 65)) {
        return if $x < 0 || $y < 0;
        return if $x >= $netbar_sidebar_mouse_width - 1
            || $y >= $netbar_sidebar_mouse_height;
        int($button) == 64
            ? netbar_command_nav_up()
            : netbar_command_nav_down();
        return;
    }

    # VT200 mode reports a release as button 3. Accept only an unmoved
    # left/right click; this avoids treating a drag as a dashboard action.
    return unless defined($button) && int($button) == 3;
    return unless defined($old_button)
        && (int($old_button) == 0 || int($old_button) == 2);
    return unless int($x) == int($old_x) && int($y) == int($old_y);

    my $hitbox = netbar_mouse_hitbox_at($x, $y);
    return unless $hitbox;

    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();
            @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();
        @netbar_mouse_sgr_press = ();
        $netbar_mouse_sgr_press_time = 0;
        return 0;
    }

    my $payload = $netbar_mouse_sgr_buffer;
    netbar_mouse_finish_capture();

    return 0 unless $payload =~ /\A(\d+);(\d+);(\d+)\z/;
    my ($encoded_button, $x, $y) = (int($1), int($2) - 1, int($3) - 1);
    return 0 if $x < 0 || $y < 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();
        }
        else {
            @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 unmoved, 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
            && $x == $old_x && $y == $old_y
            && $press_age >= 0 && $press_age <= 5.0) {
            netbar_mouse_event(3, $x, $y, $old_button, $old_x, $old_y);
        }
    }
    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();
        @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]--;

        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 number of dashboard columns 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 {
    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 = mxl_saved_external_mouse_binding_present() ? 1 : 0;

    Irssi::command_bind('mxlmousexterm', \&netbar_mouse_command_xterm);
    Irssi::command_bind('mxlmousesgr',   \&netbar_mouse_command_sgr);

    # Never steal a pre-existing raw mouse binding from another script. MXL's
    # dashboard still tracks terminal resizes, but mouse clicks stay with the
    # existing owner until MXL is reloaded after that conflict is removed.
    if (!$netbar_mouse_binding_conflict) {
        Irssi::command('^bind meta-[M command MXLMOUSEXTERM');
        Irssi::command('^bind meta-[< command MXLMOUSESGR');
        $mxl_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 {
    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('mxlmousexterm', \&netbar_mouse_command_xterm);
        Irssi::command_unbind('mxlmousesgr',   \&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('mxl_mouse_enabled')
            ? 'ON' : 'OFF';
        my $offset = Irssi::settings_get_int('mxl_mouse_offset');
        my $protocol = netbar_mouse_protocol();
        Irssi::print(
            'MXL 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, right click=/wc.'
        );
        return;
    }

    if (lc($data) eq 'on') {
        if ($netbar_mouse_binding_conflict) {
            Irssi::print('MXL mouse: external meta-[M/meta-[< binding detected; mouse takeover is disabled until the conflict is removed and MXL is reloaded.', Irssi::MSGLEVEL_CLIENTNOTICE());
            return;
        }
        Irssi::settings_set_bool('mxl_mouse_enabled', 1);
        netbar_mouse_tracking_enable();
        Irssi::print('MXL mouse: enabled.');
        return;
    }

    if (lc($data) eq 'off') {
        Irssi::settings_set_bool('mxl_mouse_enabled', 0);
        netbar_mouse_tracking_disable();
        Irssi::print('MXL mouse: disabled.');
        return;
    }


    if (lc($data) eq 'precise') {
        Irssi::settings_set_str('mxl_mouse_protocol', 'precise');
        if (Irssi::settings_get_bool('mxl_mouse_enabled')) {
            netbar_mouse_tracking_disable();
            netbar_mouse_tracking_enable();
        }
        Irssi::print('MXL 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.
        Irssi::settings_set_str('mxl_mouse_protocol', 'precise');
        if (Irssi::settings_get_bool('mxl_mouse_enabled')) {
            netbar_mouse_tracking_disable();
            netbar_mouse_tracking_enable();
        }
        Irssi::print('MXL mouse: links mode was retired; restored mode=precise.');
        return;
    }

    if ($data =~ /^offset\s+(\d+)$/i) {
        my $offset = int($1);
        if ($offset > 20) {
            Irssi::print('MXL mouse: offset must be between 0 and 20.');
            return;
        }
        Irssi::settings_set_int('mxl_mouse_offset', $offset);
        Irssi::print('MXL mouse: offset set to ' . $offset . '.');
        return;
    }

    Irssi::print('Usage: /mxlmouse [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 automatic shared channel grid rendered in the stacked
# statusbars. The largest column count which fits the complete dashboard is used
# by every network; it shrinks only after a terminal resize makes it necessary.
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 = netbar_sidebar_mode()
        ? 1
        : netbar_choose_shared_columns(
            \@network_data,
            1,
            netbar_active_width(),
        );
    $columns = 1 if !$columns || $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 lc($entry->{name} // '') eq lc($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 lc($entry->{nick} // '') eq lc($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};
    return netbar_focus_item($entry->{query});
}

# Alt-Left / Alt-Right stay inside the current visual row. At the row edge they
# wrap to the opposite edge of that row. From Status or QUERY they enter the
# first/last channel of the selected network.
sub netbar_navigate_horizontal {
    my ($direction) = @_;
    $direction = $direction < 0 ? -1 : 1;

    my $network_data_ref = netbar_navigation_grid();
    return unless @$network_data_ref;

    my $context = netbar_navigation_context($network_data_ref);
    return unless $context;

    my $current_data = $network_data_ref->[$context->{network_index}];
    my $columns = int($current_data->{columns} || 1);
    $columns = 1 if $columns < 1;

    if ($context->{kind} eq 'QUERY') {
        my $queries = $network_data_ref->[$context->{network_index}]->{queries} || [];
        my $count = scalar @$queries;
        return unless $count;

        my $current = defined($context->{query_index})
            ? $context->{query_index}
            : ($direction < 0 ? 0 : $count - 1);
        my $target = ($current + $direction + $count) % $count;
        netbar_navigation_focus_query_entry($queries->[$target]);
        return;
    }

    my $entries = $current_data->{channels};
    my $count = scalar @$entries;
    return unless $count;

    my $target_index;
    if ($context->{kind} eq 'CHANNEL' && defined($context->{channel_index})) {
        my $current = $context->{channel_index};
        my ($row) = netbar_grid_position_for_index($current, $columns);
        my @row_indices;
        for my $column (0 .. $columns - 1) {
            my $index = netbar_grid_index_for_position($row, $column, $columns, $count);
            push @row_indices, $index if defined $index;
        }
        return unless @row_indices;

        my $position = 0;
        for my $i (0 .. $#row_indices) {
            if ($row_indices[$i] == $current) {
                $position = $i;
                last;
            }
        }
        $position = ($position + $direction + scalar(@row_indices))
            % scalar(@row_indices);
        $target_index = $row_indices[$position];
    }
    else {
        $target_index = $direction < 0 ? $count - 1 : 0;
    }

    netbar_navigation_focus_entry($entries->[$target_index]);
}

# 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') {
        my $queries = $network_data_ref->[$network_index]->{queries} || [];
        my $count = scalar @$queries;
        return unless $count;

        my $current = defined($context->{query_index})
            ? $context->{query_index}
            : ($direction < 0 ? 0 : $count - 1);
        my $target = ($current + $direction + $count) % $count;
        netbar_navigation_focus_query_entry($queries->[$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_signal_window_focus_changed {
    my ($signal_window, $previous_window) = @_;
    return if $netbar_sidebar_layout_guard;

    my $active = Irssi::active_win();
    if (netbar_sidebar_mode() && $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_pm_reset_page_for_item($active ? $active->{active} : undef);
    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 lc($entry->{name} || '') eq lc($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 lc($entry->{name} || '') eq lc($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 (netbar_sidebar_mode() && $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 {
    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 { lc($_->{name}) eq lc($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_own_channel_prefix {
    my ($channel, $server) = @_;
    return '' unless $channel && $server && length($server->{nick} // '');

    my $nick_record = eval { $channel->nick_find($server->{nick}) };
    if ($nick_record) {
        return '@' if $nick_record->{op};
        return '%' if $nick_record->{halfop};
        return '+' if $nick_record->{voice};
    }

    return '@' if $channel->{chanop};
    return '';
}

sub netbar_identity_data {
    my ($selected) = @_;
    return ('', '') unless $selected;

    my $server  = $selected->{server};
    my $channel = $selected->{channel};
    return ('', '') unless $server;

    my $prefix = netbar_own_channel_prefix($channel, $server);
    $prefix = ' ' unless length $prefix;

    my $nick_plain = netbar_plain_dynamic_text($server->{nick} // '');
    my $mode_plain = netbar_mode_plain($server->{usermode} // '');

    my $plain = $prefix . $nick_plain;
    $plain .= '(' . $mode_plain . ')' if length $mode_plain;

    # The header builder applies one colour to the complete identity. Keeping
    # colour codes out of this fragment also guarantees fixed-width alignment.
    return ($plain, netbar_format_dynamic_text($plain));
}

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
    # dashboard 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 = lc($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) && lc($nick) eq $own;
            if ($oper_nicks_by_tag{$tag}->{lc $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 mxl_text_width($name) <= $max_length;
    return '~' if $max_length == 1;
    return mxl_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;

    if (($active || $is_muted) && length $activity_key) {
        delete $message_activity{$activity_key};
        delete $nick_attention{$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;
    # a direct nick mention additionally makes the window number pulse.
    my $marker_char = $active ? '>' : ' ';
    # Sidebar uses the same blue as its Status label for the selection arrow.
    # Keep the established red marker in COMPACT/NORMAL; this request is
    # intentionally visual-only and limited to the left panel.
    my $marker_colour = $active
        ? (netbar_sidebar_mode() ? '%B' : '%R')
        : '%K';

    # Inactive channels with new text stay normal gray. A direct mention
    # keeps the slash and channel name steady and pulses 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)
        ? ($mxl_pm_pulse_on ? '%M' : '%K')
        : $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/pulses.
    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 $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 = mxl_text_width($marker_plain)
            + mxl_text_width($refnum_plain) + 1 + 2
            + (length($shown_mode_plain) ? mxl_text_width($shown_mode_plain) + 2 : 0)
            + ($is_muted ? 4 : 0);
        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 . $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 . ' *';
    $reserved_plain .= '(' . $mode_plain . ')' if length $mode_plain;
    $reserved_plain .= $mute_plain;

    my $formatted = $marker_formatted
        . $refnum_formatted
        . '%K/' . $state_colour
        . $name_formatted
        . $oper_formatted
        . $shown_mode_formatted
        . $mute_formatted;

    return {
        plain          => $plain,
        reserved_plain => $reserved_plain,
        formatted      => $formatted,
    };
}

sub netbar_column_widths {
    my ($display_ref, $columns) = @_;
    my @widths = (0) x $columns;

    for my $index (0 .. $#$display_ref) {
        my $column = netbar_grid_column_for_index($index, $columns);
        my $width = mxl_text_width($display_ref->[$index]->{reserved_plain});
        $widths[$column] = $width if $width > $widths[$column];
    }

    return @widths;
}

# Expand the natural widths of one network's columns so the grid uses the
# available row without leaving one large black block on the right. One safety
# cell remains at the edge to prevent terminal autowrap.
sub netbar_expand_column_widths {
    my ($widths_ref, $columns, $channel_prefix_length, $window_width) = @_;
    my @widths = @$widths_ref;
    return @widths unless $columns > 0 && @widths;

    my $separator_width = 3 * ($columns - 1);
    my $available = $window_width
        - $channel_prefix_length
        - $separator_width
        - 1;
    return @widths if $available <= 0;

    my $used = 0;
    $used += $_ for @widths;
    my $extra = $available - $used;
    return @widths if $extra <= 0;

    my $base = int($extra / $columns);
    my $remainder = $extra % $columns;
    for my $column (0 .. $columns - 1) {
        $widths[$column] += $base;
        $widths[$column]++ if $column < $remainder;
    }

    return @widths;
}

# Return natural widths shared by corresponding columns of every network. This
# keeps IRCnet, IRCnet2 and additional networks on the same terminal grid.
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 = mxl_text_width($display_ref->[$index]->{reserved_plain});
            $widths[$column] = $width if $width > $widths[$column];
        }
    }

    return @widths;
}

# The dashboard has two layout modes. NORMAL may use up to eight columns and
# fills each column vertically in groups of three channels. COMPACT preserves
# the 4.9.14 row-major layout with at most three natural-width columns / two
# separators. Both modes still shrink automatically on a narrow terminal and
# use the same shared grid for every connected network.
sub netbar_layout_mode {
    my $mode = lc(Irssi::settings_get_str('network_statusbars_layout_mode') // 'compact');
    return $mode eq 'normal' ? 'normal' : 'compact';
}

sub netbar_max_channel_columns {
    return netbar_layout_mode() eq 'normal'
        ? $MAX_CHANNELS_ROW_NORMAL
        : $MAX_CHANNELS_ROW_COMPACT;
}

# NORMAL is column-major in groups of three rows:
#   1  4  7
#   2  5  8
#   3  6  9
# Additional channels continue in another three-row block below. COMPACT keeps
# the original 4.9.14 row-major layout. These helpers are shared by rendering,
# width calculation and Alt-arrow navigation so every path follows the same
# geometry.
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 if netbar_layout_mode() eq 'compact';

    my $rows_per_column = 3;
    my $block_size = $columns * $rows_per_column;
    my $within = $index % $block_size;
    return int($within / $rows_per_column);
}

sub netbar_grid_position_for_index {
    my ($index, $columns) = @_;
    $index = int($index // 0);
    $columns = int($columns // 1);
    $columns = 1 if $columns < 1;

    if (netbar_layout_mode() eq 'compact') {
        return (int($index / $columns), $index % $columns);
    }

    my $rows_per_column = 3;
    my $block_size = $columns * $rows_per_column;
    my $block = int($index / $block_size);
    my $within = $index % $block_size;
    my $column = int($within / $rows_per_column);
    my $row_in_block = $within % $rows_per_column;
    return ($block * $rows_per_column + $row_in_block, $column);
}

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;
    if (netbar_layout_mode() eq 'compact') {
        $index = $row * $columns + $column;
    }
    else {
        my $rows_per_column = 3;
        my $block = int($row / $rows_per_column);
        my $row_in_block = $row % $rows_per_column;
        $index = $block * ($columns * $rows_per_column)
            + $column * $rows_per_column
            + $row_in_block;
    }

    return undef if $index < 0 || $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)
        if netbar_layout_mode() eq 'compact';

    my $rows_per_column = 3;
    my $block_size = $columns * $rows_per_column;
    my $full_blocks = int($count / $block_size);
    my $remainder = $count % $block_size;
    my $tail_rows = $remainder > $rows_per_column
        ? $rows_per_column
        : $remainder;
    return $full_blocks * $rows_per_column + $tail_rows;
}

sub netbar_grid_visible_columns {
    my ($count, $columns) = @_;
    $count = int($count // 0);
    $columns = int($columns // 1);
    $columns = 1 if $columns < 1;
    return 0 if $count < 1;

    if (netbar_layout_mode() eq 'compact') {
        return $count < $columns ? $count : $columns;
    }

    my $visible = int(($count + 2) / 3);
    $visible = $columns if $visible > $columns;
    return $visible;
}

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 netbar_choose_shared_columns {
    my ($network_data_ref, $channel_prefix_length, $window_width) = @_;

    my $largest_count = 0;
    for my $data (@$network_data_ref) {
        my $count = scalar @{ $data->{display} || [] };
        $largest_count = $count if $count > $largest_count;
    }
    return 1 if $largest_count <= 1;

    my $limit = netbar_max_channel_columns();
    my $max = $largest_count < $limit
        ? $largest_count
        : $limit;

    for (my $columns = $max; $columns >= 1; $columns--) {
        my @widths = netbar_shared_column_widths($network_data_ref, $columns);
        my $visible_columns = netbar_grid_visible_columns($largest_count, $columns);
        $visible_columns = 1 if $visible_columns < 1;
        my $separator_width = 3 * ($visible_columns - 1); # " : "
        my $total = $channel_prefix_length + $separator_width + 1;
        for my $column (0 .. $visible_columns - 1) {
            $total += $widths[$column] // 0;
        }
        return $columns if $total <= $window_width;
    }

    return 1;
}

# Build a decorative row above the channel grid.  Every ``--\\ /--``
# marker is centred over the exact terminal cell occupied by a ``:`` column
# separator. The positions are derived from this network's own column widths,
# so resizing the terminal or changing the number of columns
# cannot move the channels out of alignment.
sub netbar_build_separator_markers {
    my ($data, $widths_ref, $columns, $channel_prefix_length, $window_width) = @_;

    my $channel_count = scalar @{ $data->{display} || [] };
    my $visible_columns = netbar_grid_visible_columns($channel_count, $columns);
    return ('', '', 0) if $visible_columns <= 1;

    my $plain_pattern = '--\\ /--';
    my $pattern_width = length($plain_pattern); # seven terminal cells
    my @placements;
    my $width_sum = 0;

    # Separator after column N appears after the leading grid cell, all column
    # widths up to N, and the preceding three-cell `` : `` separators.  The
    # middle blank of ``--\\ /--`` is placed directly above the colon.
    for my $column (0 .. $visible_columns - 2) {
        $width_sum += $widths_ref->[$column] // 0;

        my $colon_x = $channel_prefix_length
            + $width_sum
            + (3 * $column)
            + 1;
        my $start_x = $colon_x - 3;

        next if $start_x < 0;
        next if $start_x + $pattern_width > $window_width;
        push @placements, $start_x;
    }

    return ('', '', 0) unless @placements;

    my $plain = '';
    my $formatted = '%0%W';
    my $cursor = 0;
    my $active_marker = '%K--%w\\\\ /%K--';
    my $inactive_marker = '%K--\\\\ /--';
    my $marker = $data->{is_active} ? $active_marker : $inactive_marker;

    for my $start_x (@placements) {
        my $gap = $start_x - $cursor;
        if ($gap > 0) {
            my $spaces = ' ' x $gap;
            $plain .= $spaces;
            $formatted .= $spaces;
        }

        $plain .= $plain_pattern;
        $formatted .= $marker;
        $cursor = $start_x + $pattern_width;
    }

    return ($formatted, $plain, length($plain));
}

sub netbar_build_active_channel_indicator {
    my ($data, $widths_ref, $columns, $channel_prefix_length, $window_width) = @_;
    return ('', '', 0) unless $data->{is_active};
    my $channels_ref = $data->{channels} || [];
    my $display_ref  = $data->{display}  || [];
    return ('', '', 0) unless @$channels_ref && @$display_ref;

    my $active_index;
    for my $index (0 .. $#$channels_ref) {
        if ($channels_ref->[$index]->{active}) {
            $active_index = $index;
            last;
        }
    }
    return ('', '', 0) unless defined $active_index;

    my $column = netbar_grid_column_for_index($active_index, $columns);
    my $column_start = $channel_prefix_length;
    for (my $prior = 0; $prior < $column; $prior++) {
        $column_start += ($widths_ref->[$prior] // 0) + 3;
    }
    my $column_width = $widths_ref->[$column] // 0;
    return ('', '', 0) if $column_width <= 0;

    my $guide_x = $column == 0
        ? $channel_prefix_length - 1
        : $column_start - 1;
    $guide_x = 0 if $guide_x < 0;
    $guide_x = $window_width - 1 if $guide_x >= $window_width;
    my $marker_spaces = ' ' x $guide_x;
    my $marker_plain = $marker_spaces . '│';
    my $marker_text = '%0%W' . $marker_spaces . '%W│';
    return ($marker_text, $marker_plain, length($marker_plain));
}

sub netbar_build_sidebar_layout_rows {
    my @networks = netbar_connected_networks();
    my @network_data;
    my $network_width = 0;
    my $identity_width = 0;
    my $refnum_width = 2;
    my $window_width = netbar_active_width();
    my $channel_prefix_length = $CHANNEL_PREFIX_LENGTH;
    $last_seen_width = $window_width;

    # First collect all networks and determine widths shared by the complete
    # dashboard, including one common window-number width.
    for my $network (@networks) {
        my @channels = netbar_channel_entries_for_network($network);
        my $selected = netbar_selected_entry_for_network($network, \@channels);
        my ($identity_plain, $identity_formatted) = netbar_sidebar_mode()
            ? netbar_status_identity_data($network)
            : netbar_identity_data($selected);
        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} // '')
            : '';

        my $network_cells = mxl_text_width($network_plain);
        my $identity_cells = mxl_text_width($identity_plain);
        $network_width = $network_cells if $network_cells > $network_width;
        $identity_width = $identity_cells if $identity_cells > $identity_width;

        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,
        };
    }

    # 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) {
        my $network_padding = ' ' x (
            $network_width - mxl_text_width($data->{network_plain})
        );
        my $identity_padding = ' ' x (
            $identity_width - mxl_text_width($data->{identity_plain})
        );

        if (netbar_sidebar_mode()) {
            # The header contains only the network name. Status notifications
            # never animate this row; they pulse the Status word below it.
            $data->{header_plain} = '[' . $data->{network_plain} . ']';
            # Every connected network remains visible in bright white. The
            # active channel/Status marker still shows the current context.
            my $header_colour = '%W';
            $data->{header_formatted} = '%K[' . $header_colour
                . 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  ';
            # Match the blue sidebar divider while retaining the existing
            # blue/dark unread pulse until that network's Status is opened.
            my $status_colour = $data->{has_status_attention}
                ? ($mxl_pm_pulse_on ? '%B' : '%K')
                : '%B';
            my $identity_colour = $status_active ? '%W' : '%K';
            my $identity_gap = length($data->{identity_plain}) ? '   ' : '';
            my $identity_plain = length($data->{identity_plain})
                ? '[' . $data->{identity_plain} . ']'
                : '';
            my $identity_formatted = length($data->{identity_plain})
                ? '%K[' . $identity_colour
                    . $data->{identity_formatted} . '%K]'
                : '';
            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 . $identity_plain . $away_plain;
            $data->{status_formatted} = $marker_text
                . $status_colour . 'Status%K'
                . $identity_gap . $identity_formatted . $away_formatted;
        }
        else {
            $data->{header_plain} = '['
                . $data->{network_plain} . $network_padding
                . '   ' . $data->{identity_plain} . $identity_padding
                . ']';

            # Bottom-dashboard compatibility: retain its historical combined
            # header and per-network Status notification pulse.
            my $header_colour = $data->{has_status_attention}
                ? ($mxl_pm_pulse_on ? '%W' : '%K')
                : ($data->{is_active} ? '%W' : '%K');

            $data->{header_formatted} = '%K[' . $header_colour
                . netbar_format_dynamic_text($data->{network_plain})
                . $network_padding
                . '   ' . $data->{identity_formatted} . $identity_padding
                . '%K]';
        }

        # Attach the remembered/unread private conversations to their own
        # network. A PM from IRCnet2 can therefore never appear beside IRCnet.
        # Only unread window numbers pulse; the network's active/inactive colour
        # remains independent and continues to show the current context.
        if (!netbar_sidebar_mode() && defined &pmu_network_fragment) {
            my $act_prefix_plain = ' Act: ';
            my $act_prefix_text  = ' %WAct:%w ';
            my $available = $window_width - 1
                - mxl_text_width($data->{header_plain})
                - length($act_prefix_plain);

            if ($available > 0) {
                my ($pm_text, $pm_plain) = pmu_network_fragment(
                    $data->{network},
                    $available,
                );
                if (defined($pm_plain) && length($pm_plain)) {
                    $data->{header_plain} .= $act_prefix_plain . $pm_plain;
                    $data->{header_formatted} .= $act_prefix_text . $pm_text;
                }
            }
        }

        $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 = netbar_sidebar_mode()
        ? 1
        : netbar_choose_shared_columns(
            \@network_data,
            $channel_prefix_length,
            $window_width,
        );
    $last_layout_columns = $columns > 0 ? $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.
    if (netbar_sidebar_mode()) {
        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};

        # 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 + mxl_text_width($data->{header_plain}),
            mouse_kind   => netbar_sidebar_mode() ? '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;
            }
        }

        if (netbar_sidebar_mode()) {
            # 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 . '%W│'
                : '%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;
            my $guide_char = '└';
            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))
                    . '%W' . $guide_char)
                : ('%0%W' . (' ' x $channel_prefix_length));

            push @rows, {
                key          => lc($data->{network}) . "\0status",
                text         => $prefix_text . $data->{status_formatted},
                plain_length => mxl_text_width($prefix_plain)
                    + mxl_text_width($data->{status_plain}),
                mouse_kind   => 'status',
                mouse_network => $data->{network},
                mouse_x_start => $channel_prefix_length,
            };

            # One dedicated spacer separates Status from every #channel. When
            # a channel is active, only the top cap of its independent rail is
            # drawn here; otherwise the row stays completely empty.
            my $channel_selected = $data->{is_active}
                && defined($active_index);
            my $channel_gap_plain = $channel_selected
                ? $marker_spaces . '│'
                : '';
            my $channel_gap_text = $channel_selected
                ? '%0%W' . $marker_spaces . '%W│'
                : '%0%W';

            push @rows, {
                key          => lc($data->{network}) . "\0status_gap",
                text         => $channel_gap_text,
                plain_length => length($channel_gap_plain),
            };
        }
        # Bottom-dashboard compatibility keeps the old channel-only rail.
        elsif (@$display_ref) {
            my (
                $marker_text,
                $marker_plain,
                $marker_length,
            ) = netbar_build_active_channel_indicator(
                $data,
                \@widths,
                $columns,
                $channel_prefix_length,
                $window_width,
            );

            push @rows, {
                key          => lc($data->{network}) . "\0header_gap",
                text         => length($marker_plain) ? $marker_text : '%0%W',
                plain_length => length($marker_plain) ? $marker_length : 0,
            };
        }

        # 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] - mxl_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))
                        . '%W' . $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  %W' . $guide_char;
                            $channel_plain .= '  ' . $guide_char;
                        }
                        else {
                            $channel_text .= '%K : ';
                            $channel_plain .= ' : ';
                        }
                    }
                    $channel_text .= $parts[$column];
                    $channel_plain .= $plain_parts[$column];
                }

                my %mouse_target;
                if (netbar_sidebar_mode()) {
                    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_x_start => $channel_prefix_length,
                        );
                    }
                }

                push @rows, {
                    key          => lc($data->{network}) . "\0channels\0" . $row_index,
                    text         => $prefix_text . $channel_text,
                    plain_length => mxl_text_width($prefix_plain)
                        + mxl_text_width($channel_plain),
                    %mouse_target,
                };
            }
        }

        # 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;
}

sub netbar_build_layout_rows {
    return netbar_sidebar_mode()
        ? netbar_build_sidebar_layout_rows()
        : netbar_build_bottom_layout_rows();
}

sub netbar_build_bottom_layout_rows {
    my @networks = netbar_connected_networks();
    my @network_data;
    my $network_width = 0;
    my $identity_width = 0;
    my $refnum_width = 2;
    my $window_width = netbar_active_width();
    my $channel_prefix_length = 1; # one black-background cell before the grid
    $last_seen_width = $window_width;

    # First collect all networks and determine widths shared by the complete
    # dashboard, including one common window-number width.
    for my $network (@networks) {
        my @channels = netbar_channel_entries_for_network($network);
        my $selected = netbar_selected_entry_for_network($network, \@channels);
        my ($identity_plain, $identity_formatted) = netbar_identity_data($selected);
        my $network_plain = netbar_plain_dynamic_text($network);

        my $network_cells = mxl_text_width($network_plain);
        my $identity_cells = mxl_text_width($identity_plain);
        $network_width = $network_cells if $network_cells > $network_width;
        $identity_width = $identity_cells if $identity_cells > $identity_width;

        for my $entry (@channels) {
            my $digits = length('' . int($entry->{refnum}));
            $refnum_width = $digits if $digits > $refnum_width;
        }

        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} // '')
            : '';

        push @network_data, {
            network              => $network,
            network_plain        => $network_plain,
            identity_plain       => $identity_plain,
            identity_formatted   => $identity_formatted,
            is_active            => netbar_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,
        };
    }

    # 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 automatic
    # shared grid across every network.
    for my $data (@network_data) {
        my $network_padding = ' ' x (
            $network_width - mxl_text_width($data->{network_plain})
        );
        my $identity_padding = ' ' x (
            $identity_width - mxl_text_width($data->{identity_plain})
        );

        $data->{header_plain} = '['
            . $data->{network_plain} . $network_padding
            . '   ' . $data->{identity_plain} . $identity_padding
            . ']';
        $data->{header_status_plain_length} =
            mxl_text_width($data->{header_plain});

        # Restore the earlier monochrome network-header palette: the active
        # network is bright white, inactive networks are dark gray, and a
        # status-window alert pulses white / dark gray.
        my $header_colour = $data->{has_status_attention}
            ? ($mxl_pm_pulse_on ? '%W' : '%K')
            : ($data->{is_active} ? '%W' : '%K');

        $data->{header_formatted} = '%K[' . $header_colour
            . netbar_format_dynamic_text($data->{network_plain})
            . $network_padding
            . '   ' . $data->{identity_formatted} . $identity_padding
            . '%K]';

        my @pm_entries = defined(&pmu_network_stack_entries)
            ? pmu_network_stack_entries($data->{network})
            : ();
        $data->{pm_total} = scalar @pm_entries;

        $data->{display} = [ map {
            netbar_channel_display_data($_, undef, $refnum_width)
        } @{ $data->{channels} } ];
    }

    # Use one shared grid for every network. Column width is first calculated
    # from the longest real channel entry occupying that column across ALL
    # connected networks (including refnum, operator marker, mode and mute tag).
    # In COMPACT mode keep those natural widths, so the separators sit directly
    # after the longest required entry instead of being stretched across the
    # whole terminal. NORMAL preserves the original full-width appearance.
    my $columns = netbar_choose_shared_columns(
        \@network_data,
        $channel_prefix_length,
        $window_width,
    );
    my @widths = netbar_shared_column_widths(\@network_data, $columns);
    if (netbar_layout_mode() eq 'normal') {
        @widths = netbar_expand_column_widths(
            \@widths,
            $columns,
            $channel_prefix_length,
            $window_width,
        );
    }

    my $total = $channel_prefix_length + 3 * ($columns - 1) + 1;
    $total += $_ for @widths;

    # If even one shared column is wider than the terminal, truncate every
    # network with the same limit so the common grid 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);
        if (netbar_layout_mode() eq 'normal') {
            @widths = netbar_expand_column_widths(
                \@widths,
                $columns,
                $channel_prefix_length,
                $window_width,
            );
        }
    }

    for my $data (@network_data) {
        $data->{columns} = $columns;
        $data->{column_widths} = \@widths;
    }

    # network_statusbars_pm_rows is the expansion batch size (normally five).
    # The first batch is visible immediately; each click on +N appends another
    # batch below it. It is not a page switch.
    my $pm_item_limit = Irssi::settings_get_int('network_statusbars_pm_rows');
    $pm_item_limit = 6 if !defined($pm_item_limit) || $pm_item_limit < 1;
    $pm_item_limit = 20 if $pm_item_limit > 20;

    my @rows;

    for my $network_index (0 .. $#network_data) {
        my $data = $network_data[$network_index];
        my $display_ref = $data->{display};
        my $columns = int($data->{columns} || 1);
        $columns = 1 if $columns < 1;
        my @widths = @{ $data->{column_widths} || [] };

        # AWAY is kept per network and is shown immediately after that
        # network's header. Act: still appears only when at least one QUERY is
        # open. Continuation PM rows begin exactly under the first PM, including
        # the extra width consumed by the optional AWAY label and reason.
        my $header_plain = $data->{header_plain};
        my $header_text = $data->{header_formatted};

        my $away_plain = '';
        my $away_text  = '';
        if ($data->{is_away}) {
            my $configured_max = Irssi::settings_get_int(
                'network_statusbars_away_max_length'
            );
            $configured_max = 32
                if !defined($configured_max) || $configured_max < 1;
            $configured_max = 120 if $configured_max > 120;

            # Keep a small useful area for Act: and at least one compact PM.
            # On narrow terminals the reason is shortened first; if necessary
            # only the word Away remains, never allowing the row to overflow.
            my $pm_reserve = $data->{pm_total} > 0
                ? length('  Act: ') + 6
                : 0;
            my $reason_room = $window_width
                - mxl_text_width(' ' . $header_plain)
                - length('  Away: ')
                - $pm_reserve;
            $reason_room = $configured_max if $reason_room > $configured_max;

            if ($reason_room > 0 && length($data->{away_reason} // '')) {
                my $shown_reason = netbar_truncate_name(
                    $data->{away_reason},
                    $reason_room,
                );
                $away_plain = '  Away: ' . $shown_reason;
                $away_text  = '%K  Away:%w '
                    . netbar_format_dynamic_text($shown_reason);
            }
            elsif ($window_width - mxl_text_width(' ' . $header_plain) - $pm_reserve
                >= length('  Away')) {
                $away_plain = '  Away';
                $away_text  = '%K  Away';
            }
        }

        my $act_plain = $data->{pm_total} > 0 ? '  Act: ' : '';
        my $act_text  = $data->{pm_total} > 0 ? '  %WAct:%w ' : '';
        my $first_prefix_plain = ' ' . $header_plain . $away_plain . $act_plain;
        my $first_prefix_text  = '%0%W ' . $header_text . $away_text . $act_text;
        my $pm_column_start = mxl_text_width($first_prefix_plain);
        my $pm_available = $window_width - $pm_column_start;
        $pm_available = 1 if $pm_available < 1;

        my $network_key = lc($data->{network});
        my $visible_limit = int(
            $pm_visible_by_network{$network_key} // $pm_item_limit
        );
        $visible_limit = $pm_item_limit if $visible_limit < $pm_item_limit;
        $visible_limit = $data->{pm_total}
            if $data->{pm_total} > 0 && $visible_limit > $data->{pm_total};
        $pm_visible_by_network{$network_key} = $visible_limit
            if $data->{pm_total} > 0;
        delete $pm_visible_by_network{$network_key}
            if $data->{pm_total} < 1;

        my $act_data = defined(&pmu_inline_rows_for_network)
            ? pmu_inline_rows_for_network(
                $data->{network},
                $visible_limit,
                $pm_available,
                $pm_available,
                $pm_item_limit,
            )
            : { rows => [] };
        my $act_rows = $act_data->{rows} || [];

        if (!@$act_rows) {
            # No PM: Act: remains invisible. A per-network AWAY marker may still
            # follow the header and disappears immediately after /AWAY is cleared.
            push @rows, {
                key          => lc($data->{network}) . "\0header\00",
                text         => '%0%W ' . $header_text . $away_text,
                plain_length =>
                    mxl_text_width(' ' . $header_plain . $away_plain),
                hitboxes     => [{
                    x_start => 1,
                    x_end   => $data->{header_status_plain_length},
                    type    => 'STATUS',
                    network => $data->{network},
                }],
            };
        }
        else {
            for my $act_index (0 .. $#$act_rows) {
                my $act_row = $act_rows->[$act_index];
                my $row_prefix_plain = $act_index == 0
                    ? $first_prefix_plain
                    : (' ' x $pm_column_start);
                my $row_prefix_text = $act_index == 0
                    ? $first_prefix_text
                    : ('%0%W' . (' ' x $pm_column_start));
                my @act_hitboxes;

                if ($act_index == 0) {
                    push @act_hitboxes, {
                        x_start => 1,
                        x_end   => $data->{header_status_plain_length},
                        type    => 'STATUS',
                        network => $data->{network},
                    };
                }

                for my $segment (@{ $act_row->{segments} || [] }) {
                    push @act_hitboxes, {
                        %$segment,
                        x_start => $pm_column_start + int($segment->{x_start}),
                        x_end   => $pm_column_start + int($segment->{x_end}),
                    };
                }

                push @rows, {
                    key          => lc($data->{network}) . "\0header_act\0" . $act_index,
                    text         => $row_prefix_text . $act_row->{text},
                    plain_length => mxl_text_width($row_prefix_plain)
                        + mxl_text_width($act_row->{plain}),
                    hitboxes     => \@act_hitboxes,
                };
            }
        }

        # This gap is unconditional and follows the last visible PM row. When
        # +N expands the block, the gap and all channel rows move down together.
        push @rows, {
            key          => lc($data->{network}) . "\0header_pm_gap",
            text         => '%0%W',
            plain_length => 0,
            hitboxes     => [],
        };

        # The former blank header gap now carries compact ``--\ /--`` markers
        # centred above the shared ``:`` separators.  With a one-column layout
        # there is no separator to mark, so retain the original empty gap.
        if (@$display_ref) {
            my ($marker_text, $marker_plain, $marker_length)
                = netbar_build_separator_markers(
                    $data,
                    \@widths,
                    $columns,
                    $channel_prefix_length,
                    $window_width,
                );

            push @rows, {
                key          => lc($data->{network}) . "\0header_gap",
                text         => length($marker_plain) ? $marker_text : '%0%W',
                plain_length => length($marker_plain) ? $marker_length : 0,
            };
        }

        # Following rows use the same geometry as navigation. COMPACT keeps
        # the 4.9.14 row-major layout. NORMAL fills each column vertically with
        # three channels before starting the next column (1/2/3, 4/5/6, 7/8/9).
        if (@$display_ref) {
            my $channel_count = scalar @$display_ref;
            my $row_count = netbar_grid_row_count($channel_count, $columns);

            for my $row_index (0 .. $row_count - 1) {
                my @parts;
                my @plain_parts;
                my @channel_hitboxes;
                my $cursor = $channel_prefix_length;

                for my $column (0 .. $columns - 1) {
                    my $index = netbar_grid_index_for_position(
                        $row_index,
                        $column,
                        $columns,
                        $channel_count,
                    );
                    last unless defined $index;

                    my $part = $display_ref->[$index];
                    my $entry = $data->{channels}->[$index];
                    my $cell_width = $widths[$column];
                    my $padding = ' ' x (
                        $cell_width - mxl_text_width($part->{plain})
                    );
                    push @parts, $part->{formatted} . $padding;
                    push @plain_parts, $part->{plain} . $padding;

                    if ($entry && $entry->{server}) {
                        push @channel_hitboxes, {
                            x_start => $cursor,
                            x_end   => $cursor + $cell_width - 1,
                            type    => 'CHANNEL',
                            tag     => netbar_server_tag($entry->{server}),
                            name    => $entry->{name} // '',
                        };
                    }

                    $cursor += $cell_width;
                    my $next_index = $column < $columns - 1
                        ? netbar_grid_index_for_position(
                            $row_index,
                            $column + 1,
                            $columns,
                            $channel_count,
                        )
                        : undef;
                    $cursor += 3 if defined $next_index;
                }

                my $channel_text = @parts ? join('%K : ', @parts) : '';
                my $channel_plain = @plain_parts ? join(' : ', @plain_parts) : '';

                push @rows, {
                    key          => lc($data->{network}) . "\0channels\0" . $row_index,
                    text         => '%0%W ' . $channel_text,
                    plain_length => $channel_prefix_length
                        + mxl_text_width($channel_plain),
                    hitboxes     => \@channel_hitboxes,
                };
            }
        }

        # 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;
}

sub netbar_render_slot {
    my ($slot, $item, $get_size_only) = @_;
    return unless $item;

    # The network item itself occupies and paints the entire row. There is no
    # second flexible filler to race with it during a channel switch.
    if ($get_size_only) {
        $item->{min_size} = 1;
        $item->{max_size} = 4096;
        return;
    }

    my $text = $slot_text[$slot] // '%0%W';
    my $plain_length = int($slot_plain_length[$slot] // 0);
    my $width = defined($item->{size}) ? int($item->{size}) : 0;
    my $padding = $width > $plain_length ? $width - $plain_length : 0;

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

sub netbar_register_statusbar_items {
    for my $slot (1 .. $MAX_BARS) {
        my $slot_number = $slot;
        my $callback = netbar_callback_name($slot_number);
        {
            no strict 'refs';
            *{$callback} = sub {
                my ($item, $get_size_only) = @_;
                netbar_render_slot($slot_number, $item, $get_size_only);
            };
        }

        Irssi::statusbar_item_register(
            netbar_item_name($slot_number),
            0,
            $callback,
        );
    }
}

sub netbar_disable_bar {
    my ($slot) = @_;
    my $bar = netbar_bar_name($slot);
    Irssi::command('^statusbar modify -disable ' . $bar);
}

sub netbar_disable_all_bars {
    for my $slot (1 .. $MAX_BARS) {
        my $bar = netbar_bar_name($slot);
        Irssi::command('^statusbar add -disable -type window -placement bottom '
            . '-position 0 -visible always ' . $bar);
        Irssi::command('^statusbar modify -disable ' . $bar);
    }
    $active_bar_count = 0;
}

sub netbar_configure_bar {
    my ($slot, $position) = @_;
    my $bar  = netbar_bar_name($slot);
    my $item = netbar_item_name($slot);

    Irssi::command(
        '^statusbar add -disable -type window -placement bottom '
        . '-position "' . $position . '" -visible always ' . $bar
    );

    for my $old_item (qw(
        barstart barend mxl_light_bar_start mxl_light_bar_end
        mxl_white_fill mxl_dark_fill
    )) {
        Irssi::command('^statusbar removeitem ' . $old_item . ' ' . $bar);
    }
    Irssi::command('^statusbar removeitem ' . $item . ' ' . $bar);
    Irssi::command(
        '^statusbar additem -alignment left -priority 1000 '
        . $item . ' ' . $bar
    );
    Irssi::command(
        '^statusbar modify -nodisable -type window -placement bottom '
        . '-position "' . $position . '" -visible always ' . $bar
    );
}

sub netbar_hide_standard_identity_items {
    for my $item (qw(user window window_empty)) {
        Irssi::command('^statusbar removeitem ' . $item . ' window');
    }
}

sub netbar_restore_standard_identity_items {
    Irssi::command('^statusbar additem -priority 100 -after time user window');
    Irssi::command('^statusbar additem -priority 100 -after user window window');
    Irssi::command('^statusbar additem -priority 100 -after window window_empty window');
}

sub netbar_apply_standard_bar_choice {
    if (Irssi::settings_get_bool('network_statusbars_replace_standard')) {
        netbar_hide_standard_identity_items();
    }
    else {
        netbar_restore_standard_identity_items();
    }
}

sub netbar_refresh_all {
    $refresh_timer = undef;

    unless (Irssi::settings_get_bool('network_statusbars_enabled')) {
        netbar_disable_all_bars() if $active_bar_count || $force_layout;
        netbar_sidebar_teardown(1) if netbar_sidebar_window();
        @slot_text = ('');
        @slot_plain_length = (0);
        @slot_hitboxes = ([]);
        $netbar_mouse_layout_width = 0;
        $netbar_mouse_layout_height = 0;
        $force_layout = 0;
        netbar_mouse_apply_setting() if defined &netbar_mouse_apply_setting;
        return;
    }

    if (netbar_sidebar_mode()) {
        netbar_disable_all_bars() if $active_bar_count || $force_layout;
        @slot_text = ('');
        @slot_plain_length = (0);
        @slot_hitboxes = ([]);
        $netbar_mouse_layout_width = 0;
        $netbar_mouse_layout_height = 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; using bottom layout.',
                Irssi::MSGLEVEL_CLIENTERROR(),
            );
            $netbar_sidebar_warned = 1;
        }
    }
    elsif (netbar_sidebar_window()) {
        netbar_sidebar_teardown(1);
    }

    my @rows = netbar_build_layout_rows();
    if (@rows > $MAX_BARS) {
        if (!$row_limit_warned) {
            Irssi::print(
                'network_statusbars: only the first ' . $MAX_BARS
                . ' rows can be displayed.'
            );
            $row_limit_warned = 1;
        }
        $#rows = $MAX_BARS - 1;
    }
    else {
        $row_limit_warned = 0;
    }

    my $new_count = scalar @rows;

    # Store all text before enabling/repositioning bars. Newly visible rows are
    # therefore black and complete on their very first draw.
    for my $index (0 .. $#rows) {
        my $slot = $index + 1;
        $slot_text[$slot] = $rows[$index]->{text};
        $slot_plain_length[$slot] = $rows[$index]->{plain_length};
        $slot_hitboxes[$slot] = $rows[$index]->{hitboxes} || [];
    }

    if ($force_layout) {
        for my $slot (1 .. $new_count) {
            my $position = $BASE_POSITION + $slot - 1;
            netbar_configure_bar($slot, $position);
        }
    }
    elsif ($new_count > $active_bar_count) {
        for my $slot ($active_bar_count + 1 .. $new_count) {
            my $position = $BASE_POSITION + $slot - 1;
            netbar_configure_bar($slot, $position);
        }
    }

    for my $slot (1 .. $new_count) {
        Irssi::statusbar_items_redraw(netbar_item_name($slot));
    }

    # Disable only rows which are no longer required; never tear down every bar
    # merely because a channel became active or the terminal width changed.
    if ($new_count < $active_bar_count) {
        for my $slot ($new_count + 1 .. $active_bar_count) {
            $slot_text[$slot] = '%0%W';
            $slot_plain_length[$slot] = 0;
            $slot_hitboxes[$slot] = [];
            Irssi::statusbar_items_redraw(netbar_item_name($slot));
            netbar_disable_bar($slot);
        }
    }

    $active_bar_count = $new_count;
    $netbar_mouse_layout_width = $last_seen_width || $netbar_mouse_screen_width;
    $netbar_mouse_layout_height = $netbar_mouse_screen_height;
    $force_layout = 0;
}

sub netbar_schedule_refresh {
    return if defined $refresh_timer;
    $refresh_timer = Irssi::timeout_add_once(75, \&netbar_refresh_all, '');
}

sub netbar_pulse_attention {
    return unless keys(%nick_attention) || keys(%status_attention_by_network);
    netbar_schedule_refresh();
}

sub netbar_force_rebuild {
    $force_layout = 1;
    netbar_apply_standard_bar_choice();
    netbar_refresh_all();
}

# ----- Efficient IRC-operator discovery ------------------------------------


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 = lc($server->{nick} // '');
    return if length($own) && lc($who_nick) eq $own;
    $oper_who_collect{$tag}->{lc $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};

    # Consume the matching end-of-WHO numeric before Irssi can print it. IRC
    # servers differ in the target they echo here (0, *, o, etc.), so checking
    # that field caused occasional WHO output to leak into the Status window.
    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};

    $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,
    );

    $server->send_raw('WHO 0 o');
}

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_auto_away_owned_reconcile_server($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;

    my $away_key = netbar_away_session_key($server);
    if (length $away_key) {
        delete $away_mention_sessions{$away_key};
        if (exists $auto_away_servers{$away_key}) {
            delete $auto_away_servers{$away_key};
            netbar_auto_away_owned_persist();
        }
    }
    my $status_network = netbar_network_name_for_server($server);
    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 {
    netbar_muted_channels_load();
    netbar_oper_restart_periodic_timer();
    netbar_auto_away_restart_timer();
    netbar_matrix_input_cursor_apply();
    netbar_mouse_apply_setting() if defined &netbar_mouse_apply_setting;
    netbar_sidebar_schedule_width_recheck() if netbar_sidebar_mode();
    netbar_schedule_refresh();
}

sub netbar_activate_display_mode {
    my ($mode, $grid_mode) = @_;
    $mode = lc($mode // '');
    return 0 unless $mode eq 'sidebar' || $mode eq 'bottom';

    if (defined($grid_mode) && length($grid_mode)) {
        $grid_mode = lc $grid_mode;
        return 0 unless $grid_mode eq 'compact' || $grid_mode eq 'normal';
        Irssi::settings_set_str('network_statusbars_layout_mode', $grid_mode);
    }

    Irssi::settings_set_str('network_statusbars_layout', $mode);
    Irssi::settings_set_bool('network_statusbars_enabled', 1);

    # Close the technical split before rebuilding MXL-PUBLIC. The sidebar
    # teardown restores autostick, prompt placement and native window chrome.
    if ($mode eq 'bottom' && netbar_sidebar_window()) {
        netbar_sidebar_teardown(1);
    }

    $force_layout = 1;
    if (defined &mxl_stabilize_layout) {
        mxl_stabilize_layout();
    }
    else {
        mxl_apply_dynamic_prompt() if defined &mxl_apply_dynamic_prompt;
        netbar_apply_standard_bar_choice();
        netbar_refresh_all();
        clients_configure_statusbar() if defined &clients_configure_statusbar;
    }

    # Creating or closing the sidebar split is asynchronous inside Irssi. A
    # final window/QUERY event can otherwise run after the immediate rebuild
    # and expose the theme's blue background in one of the reserved rows.
    mxl_schedule_mode_settle() if defined &mxl_schedule_mode_settle;
    return 1;
}

sub netbar_command_mxlsidebar {
    my ($data) = @_;
    $data //= '';
    $data =~ s/^\s+|\s+$//g;
    my $command = lc $data;

    if ($command eq '' || $command eq 'status') {
        my $enabled = Irssi::settings_get_bool('network_statusbars_enabled');
        my $active = $enabled && netbar_sidebar_mode() ? 'ON' : 'OFF';
        my $automatic = Irssi::settings_get_bool(
            'network_statusbars_sidebar_auto_width'
        );
        my $width = Irssi::settings_get_int('network_statusbars_sidebar_width');
        my $width_text = $automatic ? 'AUTO' : $width;
        my $view = !$enabled ? 'OFF'
            : netbar_sidebar_mode()
                ? 'SIDEBAR'
                : 'MXL-PUBLIC ' . uc(netbar_layout_mode());
        Irssi::print(
            'MXL Sidebar: ' . $active . ', width=' . $width_text
            . ', active view=' . $view . '.'
        );
        return;
    }

    if ($command eq 'on' || $command eq 'auto' || $command eq 'on auto') {
        Irssi::settings_set_bool('network_statusbars_sidebar_auto_width', 1)
            if $command ne 'on';
        netbar_activate_display_mode('sidebar');
        Irssi::print('MXL Sidebar: enabled; all sidebar navigation, mouse, Act and MAP functions are active.');
        return;
    }

    if ($command =~ /\A(?:width\s+|on\s+)(\d+)\z/) {
        my $width = int($1);
        if ($width < 20 || $width > 80) {
            Irssi::print('MXL Sidebar: width must be between 20 and 80 columns.');
            return;
        }
        Irssi::settings_set_bool('network_statusbars_sidebar_auto_width', 0);
        Irssi::settings_set_int('network_statusbars_sidebar_width', $width);
        netbar_activate_display_mode('sidebar');
        Irssi::print('MXL Sidebar: enabled with fixed width ' . $width . '.');
        return;
    }

    if ($command eq 'off') {
        netbar_activate_display_mode('bottom');
        Irssi::print(
            'MXL Sidebar: disabled; MXL-PUBLIC ' . uc(netbar_layout_mode())
            . ' is active.'
        );
        return;
    }

    if ($command eq 'refresh' || $command eq 'rebuild') {
        netbar_activate_display_mode('sidebar');
        Irssi::print('MXL Sidebar: rebuilt.');
        return;
    }

    if ($command =~ /^mouse(?:\s+(.*))?$/) {
        my $arg = defined($1) && length($1) ? $1 : 'status';
        netbar_command_mouse($arg);
        return;
    }

    Irssi::print('Usage: /mxlsidebar [on|off|status|auto|on N|width N|refresh|rebuild|mouse [on|off|status]]');
}

sub netbar_command_mxl_bottom_view {
    my ($wanted_mode, $label, $data) = @_;
    $wanted_mode = lc($wanted_mode // '');
    return unless $wanted_mode eq 'compact' || $wanted_mode eq 'normal';
    $label = uc($wanted_mode) unless defined($label) && length($label);

    $data //= '';
    $data =~ s/^\s+|\s+$//g;
    my $command = lc $data;

    if ($command eq '' || $command eq 'status') {
        my $enabled = Irssi::settings_get_bool('network_statusbars_enabled');
        my $active = $enabled
            && !netbar_sidebar_mode()
            && netbar_layout_mode() eq $wanted_mode
            ? 'ON'
            : 'OFF';
        my $view = !$enabled ? 'OFF'
            : netbar_sidebar_mode()
                ? 'SIDEBAR'
                : 'MXL-' . uc(netbar_layout_mode());
        Irssi::print(
            $label . ': ' . $active . ', active view=' . $view . '.'
        );
        return;
    }

    if ($command eq 'on') {
        netbar_activate_display_mode('bottom', $wanted_mode);
        Irssi::print(
            $label . ': enabled. Matrix animation state was not changed.'
        );
        return;
    }

    if ($command eq 'off') {
        netbar_activate_display_mode('sidebar');
        Irssi::print(
            $label . ': disabled; MXL Sidebar is active. '
            . 'Matrix animation state was not changed.'
        );
        return;
    }

    my $command_name = $wanted_mode eq 'compact'
        ? 'mxlcompact'
        : 'mxlnormal';
    Irssi::print('Usage: /' . $command_name . ' [on|off|status]');
}

sub netbar_command_mxlcompact {
    my ($data) = @_;
    netbar_command_mxl_bottom_view('compact', 'MXL Compact', $data);
}

sub netbar_command_mxlnormal {
    my ($data) = @_;
    netbar_command_mxl_bottom_view('normal', 'MXL Normal', $data);
}

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') {
        netbar_activate_display_mode('sidebar');
        Irssi::print('network_statusbars: left sidebar enabled.');
        return;
    }

    if ($command eq 'bottom') {
        netbar_activate_display_mode('bottom');
        Irssi::print('network_statusbars: bottom dashboard 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', 1);
        netbar_hide_standard_identity_items();
        Irssi::print('network_statusbars: standard nick/window items removed.');
        return;
    }

    if ($command eq 'restore') {
        Irssi::settings_set_bool('network_statusbars_replace_standard', 0);
        netbar_restore_standard_identity_items();
        Irssi::print('network_statusbars: standard nick/window items restored.');
        return;
    }

    if ($command =~ /^layout(?:\s+(normal|compact|status))?$/) {
        my $arg = defined($1) ? lc($1) : '';
        my $current = netbar_layout_mode();

        if ($arg eq 'status') {
            Irssi::print(
                $current eq 'normal'
                    ? 'network_statusbars: layout NORMAL - up to 8 columns, 3 channels vertically per column.'
                    : 'network_statusbars: layout COMPACT - max 3 natural-width columns / 2 separators.'
            );
            return;
        }

        my $mode = length($arg)
            ? $arg
            : ($current eq 'normal' ? 'compact' : 'normal');

        Irssi::settings_set_str('network_statusbars_layout_mode', $mode);
        $force_layout = 1;
        netbar_refresh_all();
        Irssi::print(
            $mode eq 'normal'
                ? 'network_statusbars: layout NORMAL - up to 8 columns, 3 channels vertically per column.'
                : 'network_statusbars: layout COMPACT - max 3 natural-width columns / 2 separators.'
        );
        return;
    }

    Irssi::print('Usage: /netbars [sidebar|bottom|on|off|mouse [on|off|status]|refresh|rebuild|opers|clean|restore|layout [normal|compact|status]]');
}

Irssi::settings_add_bool(
    'network_statusbars',
    'network_statusbars_enabled',
    1,
);
Irssi::settings_add_str(
    'network_statusbars',
    'network_statusbars_layout',
    'sidebar',
);
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',
    1,
);
Irssi::settings_add_str(
    'network_statusbars',
    'network_statusbars_order',
    'IRCnet IRCnet2',
);
Irssi::settings_add_str(
    'network_statusbars',
    'network_statusbars_layout_mode',
    'compact',
);
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_pm_rows',
    5,
);
Irssi::settings_add_int(
    'network_statusbars',
    'network_statusbars_away_max_length',
    32,
);
Irssi::settings_add_bool(
    'network_statusbars',
    'network_statusbars_away_notice_enabled',
    0,
);
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',
    0,
);
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',
    1,
);
Irssi::settings_add_time(
    'network_statusbars',
    'network_statusbars_auto_away_idle',
    '60min',
);
Irssi::settings_add_str(
    'network_statusbars',
    'network_statusbars_auto_away_owned_tags',
    '',
);
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',
    'mxl_mouse_enabled',
    1,
);
Irssi::settings_add_int(
    'network_statusbars',
    'mxl_mouse_offset',
    2,
);
Irssi::settings_add_bool(
    'network_statusbars',
    'mxl_mouse_confirm_close',
    1,
);
Irssi::settings_add_str(
    'network_statusbars',
    'mxl_mouse_protocol',
    'precise',
);
Irssi::settings_add_bool(
    'network_statusbars',
    'mxl_matrix_enabled',
    1,
);
Irssi::settings_add_str(
    'network_statusbars',
    'mxl_matrix_transition_mode',
    'window',
);
Irssi::settings_add_int(
    'network_statusbars',
    'mxl_matrix_duration_ms',
    360,
);
Irssi::settings_add_bool(
    'network_statusbars',
    'mxl_matrix_input_enabled',
    1,
);
Irssi::settings_add_str(
    'network_statusbars',
    'mxl_matrix_cursor_style',
    'block',
);
Irssi::settings_add_int(
    'network_statusbars',
    'mxl_matrix_input_flash_ms',
    35,
);
Irssi::settings_add_bool(
    'network_statusbars',
    'mxl_matrix_input_paste_bypass',
    1,
);

# Match the visual Matrix typing effect with the selected cursor mode while it
# is enabled. Cursor OFF, input OFF and script unload restore terminal default.
netbar_matrix_input_cursor_apply();

netbar_muted_channels_load();
netbar_register_statusbar_items();

Irssi::signal_add_last('message public',         \&netbar_signal_channel_message);
Irssi::signal_add_last('message irc action',     \&netbar_signal_channel_message);
Irssi::signal_add_first('send text',             \&netbar_auto_away_send_text_activity);
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('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_first('event 352',                   \&netbar_oper_event_who_reply);
Irssi::signal_add_first('event 315',                   \&netbar_oper_event_who_end);
# Quietly consume common WHO failure/throttle numerics only while our own
# background WHO request is active.
Irssi::signal_add_first('event 263',                   \&netbar_oper_event_who_error);
Irssi::signal_add_first('event 416',                   \&netbar_oper_event_who_error);

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::command_bind('netbars', \&netbar_command_netbars);
Irssi::command_bind('gaway',   \&netbar_command_global_away);
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('mxlnetnext', \&netbar_command_next_network);
Irssi::command_bind('mxlnavleft',  \&netbar_command_nav_left);
Irssi::command_bind('mxlnavright', \&netbar_command_nav_right);
Irssi::command_bind('mxlnavup',    \&netbar_command_nav_up);
Irssi::command_bind('mxlnavdown',  \&netbar_command_nav_down);
Irssi::command_bind('mxlmouse',    \&netbar_command_mouse);
Irssi::command_bind('mxlsidebar',  \&netbar_command_mxlsidebar);
Irssi::command_bind('mxlcompact',  \&netbar_command_mxlcompact);
Irssi::command_bind('mxlnormal',   \&netbar_command_mxlnormal);
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 MXLNETNEXT');

# Irssi names Alt-arrow keys mleft/mright/mup/mdown. Override their default
# split-window actions with navigation matching the visible network dashboard.
Irssi::command('^bind mleft  command MXLNAVLEFT');
Irssi::command('^bind mright command MXLNAVRIGHT');
Irssi::command('^bind mup    command MXLNAVUP');
Irssi::command('^bind mdown  command MXLNAVDOWN');

netbar_remember_active_item();
netbar_apply_standard_bar_choice();
netbar_refresh_all();
netbar_oper_restart_periodic_timer();
netbar_auto_away_owned_restore();
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 {
    $netbar_sidebar_shutting_down = 1;
    netbar_matrix_cancel(0);
    netbar_matrix_input_cancel();
    netbar_matrix_input_cursor_restore();
    Irssi::signal_remove('gui key pressed', \&netbar_matrix_input_key_hook);
    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;
    netbar_sidebar_teardown(1) if netbar_sidebar_window();
    netbar_sidebar_remove_black_theme_file();

    # Restore the exact navigation bindings which existed before this instance.
    mxl_restore_saved_key_bindings();
    %last_item_by_network = ();
    %away_mention_sessions = ();
    %auto_away_servers = ();
    %status_attention_quiet_until_by_network = ();
    $status_attention_suppression_depth = 0;
    %muted_channels = ();

    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;

    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};
    }

    for my $slot (1 .. $MAX_BARS) {
        my $bar = netbar_bar_name($slot);
        my $item = netbar_item_name($slot);
        Irssi::command('^statusbar removeitem ' . $item . ' ' . $bar);
        Irssi::command('^statusbar removeitem mxl_dark_fill ' . $bar);
        Irssi::command('^statusbar modify -disable ' . $bar);
    }
}

1;
}

# ============================================================================
# clients_activity.pl
# ============================================================================
{
my $ITEM         = 'clients_activity';
my $BAR          = 'mxl_network_stats_bar';
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;


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;

    my $chatnet = defined($server->{chatnet}) ? $server->{chatnet} : '';
    return $chatnet if $chatnet ne '';

    return clients_server_tag($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_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,
            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 {
    Irssi::statusbar_items_redraw($ITEM);
    Irssi::statusbar_items_redraw('mxl_dashboard');
    Irssi::statusbar_items_redraw('mxl_clock');
    Irssi::statusbar_items_redraw('mxl_sidebar_clock_pm');
    Irssi::statusbar_items_redraw('mxl_sidebar_act_overflow');
    netbar_schedule_refresh()
        if defined(&netbar_schedule_refresh)
        && defined(&netbar_sidebar_mode)
        && netbar_sidebar_mode();
}

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 $in  = $state ? int($state->{connections} // 0) : 0;
    my $out = $state ? int($state->{quits} // 0) : 0;
    my $net = $in - $out;
    my $net_text = $net >= 0 ? "+$net" : "$net";

    my $plain = 'Network:' . $current
        . ' IN:' . $in
        . ' OUT:' . $out
        . ' NET:' . $net_text;
    my $format = '%WNetwork:%K' . $current
        . ' %WIN:%K' . $in
        . ' %WOUT:%K' . $out
        . ' %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_sidebar_data {
    my $server = clients_active_server();
    my $state = clients_state_for_server($server, 0);

    my $current = $state && $state->{users_total} >= 0
        ? $state->{users_total}
        : '?';
    my $in  = $state ? int($state->{connections} // 0) : 0;
    my $out = $state ? int($state->{quits} // 0) : 0;
    my $net = $in - $out;
    my $net_text = $net >= 0 ? "+$net" : "$net";

    my $plain = 'Network:' . $current
        . ' IN:' . $in
        . ' OUT:' . $out
        . ' NET:' . $net_text;
    my $format = '%WNetwork:%K' . $current
        . ' %WIN:%K' . $in
        . ' %WOUT:%K' . $out
        . ' %WNET:%K' . $net_text;
    return ($format, $plain);
}


sub clients_dashboard_data {
    my $server = clients_active_server();
    my $state = clients_state_for_server($server, 0);

    my $current = $state && $state->{users_total} >= 0
        ? $state->{users_total}
        : '?';
    my $in  = $state ? int($state->{connections} // 0) : 0;
    my $out = $state ? int($state->{quits} // 0) : 0;
    my $net = $in - $out;
    my $net_text = $net >= 0 ? "+$net" : "$net";

    my $format = '%WNetwork:%K ' . $current
        . '  %WIN:%K ' . $in
        . '  %WOUT:%K ' . $out
        . '  %WNET:%K ' . $net_text;
    my $plain = 'Network: ' . $current
        . '  IN: ' . $in
        . '  OUT: ' . $out
        . '  NET: ' . $net_text;

    return ($format, length($plain));
}

sub clients_sb_clients_activity {
    my ($item, $get_size_only) = @_;
    my ($format) = clients_dashboard_data();
    $item->default_handler($get_size_only, '%0' . $format . ' %0%W', 0, 1);
}

sub clients_configure_statusbar {
    # Network/IN/OUT/NET now renders inside mxl_clock. Remove and disable every
    # historical standalone statistics bar so no empty row remains above it.
    Irssi::command('^statusbar removeitem clients_activity window');

    for my $bar ('clients_activity_bar', $BAR, 'mxl_sidebar_stats_bar') {
        for my $old_item (qw(
            barstart barend mxl_clock mxl_dark_fill clients_activity
            mxl_dashboard mxl_white_fill mxl_light_time mxl_light_activity
        )) {
            Irssi::command('^statusbar removeitem ' . $old_item . ' ' . $bar);
        }
        Irssi::command('^statusbar modify -disable ' . $bar);
    }

    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);

    clients_run_pending_manual_map($tag)
        if defined &clients_run_pending_manual_map;
}

sub clients_manual_map_timeout {
    my ($tag) = @_;
    my $state = $clients_state{$tag};
    return unless $state;

    $state->{manual_map_timeout_tag} = 0;
    $state->{manual_map_active} = 0;
}

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;

    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_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}) {
        # Finish and hide the already-running automatic MAP first, then show
        # the user's MAP. The caller stops Irssi's native command in this case,
        # so only the queued request is sent later.
        $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: brak aktywnego polaczenia z serwerem.',
            Irssi::MSGLEVEL_CLIENTERROR(),
        );
        return;
    }

    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;

    # 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;

    # 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->{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();
        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.
    Irssi::timeout_add_once(
        50,
        'clients_run_pending_manual_map',
        $state->{tag},
    ) if length($state->{manual_map_pending_raw} // '');
}

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_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) = @_;
    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 {
    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_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};
    delete $clients_state{$tag};
    clients_redraw_statusbar();
}

sub clients_active_context_changed {
    clients_redraw_statusbar();

    # 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::statusbar_item_register($ITEM, 0, 'clients_sb_clients_activity');

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 {
    Irssi::signal_remove('setup changed', 'clients_restart_periodic_timer');
    Irssi::timeout_remove($periodic_timer_tag) if $periodic_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::command("^statusbar removeitem $ITEM $BAR");
    Irssi::command("^statusbar removeitem mxl_dashboard $BAR");
    Irssi::command("^statusbar modify -disable $BAR");
    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 mxl 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_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 = mxl_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-\x08\x0b-\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 mxl_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 =~ s/\\e/\033/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 lc($nicklist_active_channel->{name} // '') eq lc($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 = mxl_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 = mxl_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 (mxl_text_width($shown) > $nick_cells) {
        $shown = $nick_cells == 1
            ? '~'
            : mxl_text_truncate_cells($shown, $nick_cells - 1) . '~';
    }
    my $padding = ' ' x ($nick_cells - mxl_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 {
    @nicklist_entries = ();
    $nicklist_scroll_pos = 0;

    my $channel = nicklist_current_channel();
    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 {
    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 unless $nicklist_mode == $NICKLIST_SCREEN;

    $nicklist_screen_resizing = 1;
    nicklist_screen_command('fit');

    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;

    nicklist_screen_command('width', '-w', $nicklist_irssi_width);

    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,
    );
}

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_size', 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: tryb SCREEN dziala tylko wewnatrz 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;

    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_screen_size();
    nicklist_make();
    Irssi::print('nicklist: wlaczono tryb SCREEN.');
}

sub nicklist_screen_stop {
    my ($announce) = @_;
    return unless $nicklist_mode == $NICKLIST_SCREEN;

    $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;
    Irssi::print('nicklist: wylaczono tryb SCREEN.') 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 jest pusty.', Irssi::MSGLEVEL_CLIENTERROR());
        return;
    }

    my @lst = lstat($path);
    if (!@lst) {
        require POSIX;
        if (!POSIX::mkfifo($path, 0600)) {
            Irssi::print(
                "nicklist: nie mozna utworzyc FIFO $path: $!",
                Irssi::MSGLEVEL_CLIENTERROR(),
            );
            return;
        }
        chmod 0600, $path;
        Irssi::print(
            "nicklist: utworzono prywatne FIFO. Uruchom w drugim terminalu: cat $path",
        );
        return;
    }

    if (-l _ || !-p _) {
        Irssi::print(
            "nicklist: $path nie jest bezposrednim FIFO (dowiazania sa odrzucane).",
            Irssi::MSGLEVEL_CLIENTERROR(),
        );
        return;
    }
    if ($lst[4] != $<) {
        Irssi::print(
            "nicklist: FIFO $path nie nalezy do biezacego uzytkownika.",
            Irssi::MSGLEVEL_CLIENTERROR(),
        );
        return;
    }

    chmod 0600, $path or do {
        Irssi::print(
            "nicklist: nie mozna ustawic trybu 0600 dla $path: $!",
            Irssi::MSGLEVEL_CLIENTERROR(),
        );
        return;
    };

    if (!$MXL_O_NOFOLLOW) {
        Irssi::print(
            'nicklist: system nie udostepnia O_NOFOLLOW; tryb FIFO zostal bezpiecznie odrzucony.',
            Irssi::MSGLEVEL_CLIENTERROR(),
        );
        return;
    }

    my $fh;
    my $flags = O_WRONLY | O_NONBLOCK | $MXL_O_NOFOLLOW;
    if (!sysopen($fh, $path, $flags)) {
        Irssi::print(
            "nicklist: nie mozna bezpiecznie otworzyc FIFO $path: $!. Uruchom: cat $path",
            Irssi::MSGLEVEL_CLIENTERROR(),
        );
        return;
    }

    my @opened = stat($fh);
    if (!@opened || !-p $fh || $opened[4] != $< || ($opened[2] & 0077)) {
        close $fh;
        Irssi::print(
            "nicklist: kontrola otwartego FIFO $path nie powiodla sie.",
            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: wlaczono tryb FIFO ($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;
    Irssi::print('nicklist: zamknieto FIFO.') if $announce;
}

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 - lista nickow po prawej stronie GNU screen.', $level);
    Irssi::print('%WNICKLIST FIFO%w - lista w osobnym terminalu przez FIFO.', $level);
    Irssi::print('%WNICKLIST OFF%w - wylacza aktywny tryb.', $level);
    Irssi::print('%WNICKLIST UPDATE%w - odswieza liste i ustawienia.', $level);
    Irssi::print('%WNICKLIST SCROLL N%w - przewija liste o N pozycji.', $level);
    Irssi::print('%WNICKLIST SCREENSIZE%w - ponownie dopasowuje szerokosc GNU screen.', $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_size();
    }
    else {
        Irssi::print('nicklist: tryb SCREEN nie jest aktywny.');
    }
}

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 lc($current->{name} // '') eq lc($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::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();
}

}

sub mxl_apply_dark_custom_bars {
    # Each network item now paints its complete row itself. Remove every legacy
    # filler so no right-aligned item can briefly expose or inherit blue theme
    # background while the active channel changes.
    for my $slot (1 .. 64) {
        my $bar = sprintf('network_channels_%02d', $slot);
        for my $item (qw(
            barstart barend mxl_light_bar_start mxl_light_bar_end
            mxl_white_fill mxl_dark_fill
        )) {
            Irssi::command('^statusbar removeitem ' . $item . ' ' . $bar);
        }
    }
    Irssi::command('^redraw');
}

# Disable the complete chrome owned by the layout which is being left. This is
# deliberately independent from the presence of the technical sidebar window:
# Irssi may already have destroyed that window while its per-window statusbars
# are still present in the runtime configuration.
sub mxl_prepare_owned_chrome_for_mode {
    my $sidebar = defined(&netbar_sidebar_mode) && netbar_sidebar_mode();

    if ($sidebar) {
        for my $bar (qw(
            mxl_top_gap_bar mxl_clock_bar mxl_network_gap_bar
            clients_activity_bar mxl_network_stats_bar
        )) {
            Irssi::command('^statusbar modify -disable ' . $bar);
        }
    }
    elsif (defined &netbar_sidebar_restore_chrome) {
        # Do this even when the sidebar split no longer exists. The old code
        # tied cleanup to that split and could leave one blue sidebar row behind.
        netbar_sidebar_restore_chrome();
    }
}

# 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 mxl_stabilize_layout {
    mxl_apply_message_timestamp_format();
    mxl_apply_event_formats();
    mxl_prepare_owned_chrome_for_mode();
    mxl_apply_dynamic_prompt();

    # 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;

    if (defined(&netbar_sidebar_mode) && netbar_sidebar_mode()) {
        # The ordinary per-window clock bar is visible once in every split.
        # Re-enabling it after the sidebar rebuild produced two [HH:MM] clocks
        # (one in the sidebar and one in the conversation pane). Keep only the
        # dedicated [HH:MM:SS] Act row and explicitly reapply sidebar chrome.
        netbar_sidebar_reapply_chrome();
    }
    else {
        mxl_apply_dark_window_bar();
        mxl_apply_dark_top_gap_bar();
        mxl_apply_dark_clock_bar();
        mxl_apply_dark_network_gap_bar();
    }
    mxl_apply_dark_custom_bars();

    # Remove any standalone statistics bar left by an older version, then
    # redraw the combined clock + Network/IN/OUT/NET row.
    clients_configure_statusbar() if defined &clients_configure_statusbar;
    clients_redraw_statusbar() if defined &clients_redraw_statusbar;
    Irssi::command('^redraw');
}

sub mxl_run_scheduled_layout_settle {
    $mxl_layout_settle_tag = undef;
    mxl_stabilize_layout();
}

sub mxl_schedule_layout_settle {
    Irssi::timeout_remove($mxl_layout_settle_tag)
        if defined $mxl_layout_settle_tag;

    # Debounce channel creation/join signals. With many auto-joined channels,
    # the final rebuild runs only after the last one has settled.
    $mxl_layout_settle_tag = Irssi::timeout_add_once(
        1500,
        'mxl_run_scheduled_layout_settle',
        0,
    );
}

# A layout command returns before Irssi has finished every split, active-window
# and QUERY callback. Rebuild against the current mode several times after that
# short asynchronous tail. A newer switch invalidates the older generation, so
# rapid SIDEBAR/NORMAL/COMPACT changes can never restore stale chrome.
sub mxl_run_mode_settle {
    my ($generation) = @_;
    return if $mxl_unloading;
    return unless defined($generation)
        && int($generation) == $mxl_mode_settle_generation;
    mxl_stabilize_layout();
}

sub mxl_schedule_mode_settle {
    return if $mxl_unloading;
    Irssi::timeout_remove($_) for grep { defined $_ } @mxl_mode_settle_tags;
    @mxl_mode_settle_tags = ();

    my $generation = ++$mxl_mode_settle_generation;
    for my $delay (40, 200, 800, 1800) {
        push @mxl_mode_settle_tags, Irssi::timeout_add_once(
            $delay,
            'mxl_run_mode_settle',
            $generation,
        );
    }
}

sub mxl_run_chrome_heal {
    $mxl_chrome_heal_tag = undef;
    return if $mxl_unloading;
    mxl_stabilize_layout();
}

sub mxl_schedule_chrome_heal {
    return if $mxl_unloading;
    return unless Irssi::settings_get_bool('network_statusbars_enabled');
    Irssi::timeout_remove($mxl_chrome_heal_tag)
        if defined $mxl_chrome_heal_tag;
    $mxl_chrome_heal_tag = Irssi::timeout_add_once(
        120,
        'mxl_run_chrome_heal',
        0,
    );
}

for my $signal ('server connected', 'channel created', 'channel joined') {
    Irssi::signal_add_last($signal, 'mxl_schedule_layout_settle');
}

# Closing a QUERY may destroy its window or merely move another item into the
# same window. Both operations happen after the PM bookkeeping callbacks, so a
# short debounced pass guarantees that the blank Act row remains black.
for my $signal ('query created', 'query destroyed', 'window destroyed') {
    Irssi::signal_add_last($signal, 'mxl_schedule_chrome_heal');
}

sub mxl_pm_pulse_tick {
    $mxl_pm_pulse_on = $mxl_pm_pulse_on ? 0 : 1;
    if (defined &pmu_has_unread_alerts && pmu_has_unread_alerts()) {
        pmu_redraw_statusbar();
    }
    netbar_pulse_attention() if defined &netbar_pulse_attention;
}

mxl_stabilize_layout();
@mxl_startup_settle_tags = (
    Irssi::timeout_add_once(250,  'mxl_stabilize_layout', 0),
    Irssi::timeout_add_once(1500, 'mxl_stabilize_layout', 0),
    Irssi::timeout_add_once(5000, 'mxl_stabilize_layout', 0),
);
$mxl_dark_tick_tag = Irssi::timeout_add(1000, 'mxl_dark_tick', 0);
$mxl_pm_pulse_tag = Irssi::timeout_add(650, 'mxl_pm_pulse_tick', 0);
mxl_dark_tick();

sub mxl_print_help {
    my $level = Irssi::MSGLEVEL_CLIENTCRAP();

    Irssi::print('%R[%WMXL ' . $VERSION . '%R]%W loaded. Dostepne komendy:', $level);
    Irssi::print('%W/mxlhelp%w - pokazuje ponownie te liste.', $level);
    Irssi::print('%W/nickalign%w - bez argumentu przelacza wyrownanie nickow publicznych: RIGHT (pole 9 znakow) <-> LEFT (naturalne/default Irssi, bez paddingu). Opcjonalnie: /nickalign left|right|default|status.', $level);
    Irssi::print('%W/mxlsidebar on|off|status|auto|on N|width N|refresh|rebuild%w - ON wlacza kompletny lewy panel; OFF zamyka split i wraca do MXL-PUBLIC. AUTO przywraca automatyczna szerokosc, a N ustawia 20-80 kolumn.', $level);
    Irssi::print('%W/mxlcompact on|off|status%w - ON wlacza dolny MXL-PUBLIC w trybie COMPACT; OFF wraca do MXL Sidebar. Nie zmienia ustawien animacji Matrix.', $level);
    Irssi::print('%W/mxlnormal on|off|status%w - ON wlacza dolny MXL-PUBLIC w trybie NORMAL; OFF wraca do MXL Sidebar. Nie zmienia ustawien animacji Matrix.', $level);
    Irssi::print('%W/netbars sidebar|bottom|on|off|mouse [on|off|status]|refresh|rebuild|opers|clean|restore|layout [normal|compact|status]%w - techniczne komendy obu rendererow; SIDEBAR i BOTTOM korzystaja z tego samego bezpiecznego przelacznika.', $level);
    Irssi::print('%W/gaway [powod]|off%w - bez argumentu ustawia globalnie domyslne Away; z tekstem ustawia powod; off wylacza AWAY na wszystkich sieciach. %W/away [powod]|off%w dziala tak samo tylko na aktywnej sieci.', $level);
    Irssi::print('%W/mute [#kanal] / /unmute [#kanal]%w - wycisza lub przywraca kanal na aktualnej sieci. Bez argumentu uzywa aktywnego kanalu; automatyczny /SAVE jest domyslnie wylaczony (/set network_statusbars_mute_autosave ON, aby go wlaczyc).', $level);
    Irssi::print('%WCtrl-X / /mxlnetnext%w - na kanale lub QUERY przelacza na ostatnie aktywne okno kolejnej sieci; w Status 1 jawnie przelacza wybrana siec.', $level);
    Irssi::print('%WAlt+strzalki%w - w lewym panelu gora/dol ida pionowo po kanalach i sieciach; w QUERY strzalki wybieraja poprzedni/nastepny PM tej samej sieci. Status nadal wybierasz jawnie przez Alt+1 lub /window 1.', $level);
    Irssi::print('%WMysz%w - w lewym panelu lewy klik otwiera siec, Status lub kanal, rolka porusza sie po kanalach, a prawy klik na kanale wykonuje bezpieczne /wc po powtorzeniu klikniecia. W prawym Act: lewy klik otwiera PM, a dwa prawe klikniecia w ciagu 1,5 s zamykaja je. Po zapelnieniu dwoch rzedow < wraca o 3 PM, natomiast +N i > przesuwaja o 3 PM dalej. Shift+mysz pozostaje do zaznaczania/wklejania.', $level);
    Irssi::print('%WPrompt%w - przy wpisywaniu pokazuje [siec/#kanal] lub [siec/nick], np. [IRCnet/#contempt].', $level);
    Irssi::print('%W/mxlmouse on|off|status|precise|offset N%w - steruje mysza; domyslnie dziala SGR 1006 z awaryjnym VT200, walidacja press+release i offset=2.', $level);
    Irssi::print('%W/matrix on|off|window|join|status%w - steruje wylacznie animacja, niezaleznie od SIDEBAR/COMPACT/NORMAL. WINDOW animuje klikniecie istniejacego kanalu, JOIN tylko /j i /join. Kursor: /matrix cursor przelacza BLOCK <-> UNDERLINE; /matrix cursor block|underline|off|status wybiera go jawnie, a OFF oddaje sterowanie terminalowi. Dodatkowo: /matrix duration 180-900, /matrix input on|off|test, /matrix input duration 20-120.', $level);
    Irssi::print('%W/nicklist screen|fifo|off|update|scroll N|screensize|help%w - wbudowana lista nickow aktywnego kanalu; SCREEN rysuje ja po prawej stronie GNU screen, FIFO w osobnym terminalu.', $level);
    Irssi::print('%W/clientsmap [all]%w - ciche odswiezenie Network/IN/OUT/NET dla aktywnej sieci lub wszystkich sieci.', $level);
    Irssi::print('%W/clientsreset [all]%w - reset punktu bazowego oraz licznikow IN/OUT/NET dla aktywnej sieci lub wszystkich sieci.', $level);
    Irssi::print('%W/prvguard status|on|off|unlock|purge|ctcp off|limit|strict|block%w - status i sterowanie ochrona PRV/CTCP przed floodem; przy wykryciu floodu blokuje dalszy ruch i automatycznie zamyka tylko nowe niezaufane QUERY utworzone przez atak. Istniejace i zaufane rozmowy pozostaja.', $level);
    Irssi::print('%W/pmunread%w - lista nieodczytanych rozmow prywatnych.', $level);
    Irssi::print('%W/pmclear [nick|all]%w - kasuje wskazane lub wszystkie alerty nieodczytanych PW.', $level);
    Irssi::print('%W/pmtest [nick]%w - tworzy testowy alert nieodczytanego PW.', $level);
    Irssi::print('%wSIDEBAR zachowuje sieci, Status i kanaly w jednej pionowej kolumnie, automatyczna szerokosc, Network/IN/OUT/NET oraz dwa stale wiersze Act. MXL-PUBLIC zachowuje dolne paski, wspolna siatke kanalow, Act z rozwijanym +N oraz warianty COMPACT/NORMAL. Oba widoki wspoldziela ten sam stan PM, AWAY, mute, aktywnosci, myszy i nawigacji, wiec przelaczenie niczego nie zeruje.', $level);
    Irssi::print('%wAWAY auto-NOTICE jest domyslnie wylaczony. Wlacza go /set network_statusbars_away_notice_enabled ON; powod jest dolaczany dopiero po /set network_statusbars_away_notice_include_reason ON. Raport powrotu domyslnie zapisuje tylko metadane, bez pelnej tresci (/set network_statusbars_away_store_message_text ON, aby ja zachowac). Po 60 minutach bez Twojej wiadomosci lub /me na kanale automatyczny AWAY ustawia sie tylko na sieciach bez recznego AWAY. Pierwsza aktywnosc na kanale zdejmuje wylacznie automatyczny AWAY; reczny pozostaje do /away off lub /gaway off. Samo wlaczenie/wylaczenie AWAY nie pulsuje Statusu; raport powrotu pulsuje tylko naglowki sieci, na ktorych zapisano wzmianki. Filtry spam i MASS pozostaja aktywne.', $level);
    Irssi::print('%wCTCP: PING i VERSION dzialaja takze bez istniejacego QUERY, ale nadal podlegaja limitom antyflood; odpowiedzi na Twoje /PING, /VER i /CTCP nick PING|VERSION sa przepuszczane tylko od wskazanego nicka przez 15 s.', $level);
    Irssi::print('%wMAP w tle jest ukryty; reczny /MAP jest wysylany jako zadanie manualne i jego wynik pozostaje widoczny w Status 1; wyslany /MSG otwiera Twoje QUERY, przychodzacy PW nie kradnie fokusu.', $level);
    Irssi::print('%W/mxlformats%w - natychmiast ponownie naklada aktualnie wybrane wyrownanie nickow oraz formaty JOIN/PART/QUIT/KICK/NICK/TOPIC/MODE/SYNC.', $level);
    Irssi::print('%wKolory zdarzen: JOIN >>> od szarego do jasnego, PART <<< odwrotnie, a przy QUIT i KICK czerwone jest tylko <<<; nick QUIT ma ten sam kolor co ident@host; NICK/MODE/TOPIC/SYNC/NAMES sa dopasowane do czarno-bialego ukladu.', $level);
}

sub mxl_command_help {
    mxl_print_help();
}

Irssi::command_bind('mxlhelp', 'mxl_command_help');

sub UNLOAD {
    $mxl_unloading = 1;
    mxl_restore_builtin_prompt();
    mxl_restore_event_formats();
    mxl_restore_message_timestamp_format();
    Irssi::timeout_remove($mxl_dark_tick_tag) if $mxl_dark_tick_tag;
    Irssi::timeout_remove($mxl_pm_pulse_tag) if $mxl_pm_pulse_tag;
    Irssi::timeout_remove($mxl_layout_settle_tag)
        if defined $mxl_layout_settle_tag;
    Irssi::timeout_remove($mxl_chrome_heal_tag)
        if defined $mxl_chrome_heal_tag;
    Irssi::timeout_remove($_) for grep { defined $_ } @mxl_mode_settle_tags;
    @mxl_mode_settle_tags = ();
    for my $timer (@mxl_startup_settle_tags) {
        Irssi::timeout_remove($timer) if $timer;
    }
    @mxl_startup_settle_tags = ();
    eval { nicklist_module_unload(); };
    Irssi::print('mxl cleanup warning: ' . $@) if $@;
    eval { pmu_module_unload(); };
    Irssi::print('mxl cleanup warning: ' . $@) if $@;
    eval { guard_module_unload(); };
    Irssi::print('mxl cleanup warning: ' . $@) if $@;
    eval { prvs_module_unload(); };
    Irssi::print('mxl cleanup warning: ' . $@) if $@;
    eval { netbar_module_unload(); };
    Irssi::print('mxl cleanup warning: ' . $@) if $@;
    eval { clients_module_unload(); };
    Irssi::print('mxl cleanup warning: ' . $@) if $@;

    for my $item (qw(
        mxl_clock mxl_dashboard mxl_dark_fill mxl_light_time mxl_light_activity mxl_white_fill
        mxl_prompt_context mxl_sidebar_topic mxl_sidebar_clock_pm mxl_sidebar_act_overflow
    )) {
        Irssi::command('^statusbar removeitem ' . $item . ' window');
        Irssi::command('^statusbar removeitem ' . $item . ' mxl_clock_bar');
        Irssi::command('^statusbar removeitem ' . $item . ' clients_activity_bar');
        Irssi::command('^statusbar removeitem ' . $item . ' mxl_network_stats_bar');
    }
    Irssi::command('^statusbar modify -disable mxl_clock_bar');
    Irssi::command('^statusbar modify -disable mxl_sidebar_topic_bar');
    Irssi::command('^statusbar modify -disable mxl_sidebar_topic_gap_bar');
    Irssi::command('^statusbar modify -disable mxl_sidebar_act_gap_bar');
    Irssi::command('^statusbar modify -disable mxl_sidebar_info_bar');
    Irssi::command('^statusbar modify -disable mxl_sidebar_act_overflow_bar');
    Irssi::command('^statusbar modify -disable mxl_sidebar_stats_bar');
    Irssi::command('^statusbar removeitem mxl_dashboard mxl_network_stats_bar');
    Irssi::command('^statusbar modify -disable mxl_network_stats_bar');
    Irssi::command('^statusbar removeitem mxl_dark_fill mxl_top_gap_bar');
    Irssi::command('^statusbar modify -disable mxl_top_gap_bar');
    Irssi::command('^statusbar removeitem mxl_dark_fill mxl_network_gap_bar');
    Irssi::command('^statusbar modify -disable mxl_network_gap_bar');

    for my $slot (1 .. 64) {
        my $bar = sprintf('network_channels_%02d', $slot);
        Irssi::command('^statusbar removeitem mxl_dark_fill ' . $bar);
        Irssi::command('^statusbar removeitem mxl_white_fill ' . $bar);
    }

    # Restore the exact runtime bars captured before MXL modified them. This
    # preserves custom prompt/topic/window layouts instead of replacing them
    # with Irssi defaults. timestamp_format was restored at the start of UNLOAD.
    mxl_restore_saved_statusbars();
    Irssi::command('^redraw');
}

mxl_print_help();
netbar_matrix_schedule_startup();

1;
