#!/usr/bin/perl

use strict;
use warnings;
use Gtk3 -init;
use Cairo;
use Glib;
use POSIX qw(tan log sqrt);
use constant PI => 3.14159265;
use Fcntl qw(SEEK_SET);
use Net::IP;
use File::Basename;
use Text::CSV;
use Storable;

# --- Configuration ---
my $home_dir = $ENV{'HOME'} or die "HOME environment variable not set";
my $resource_dir = "resources";
my $config_file = "connmapperlrc";
my $history_file = ".connmap-history.dat";
my $image_path = "$resource_dir/w1000-old.png";
my $ipv4_db_path = "$resource_dir/ipv4.csv";
my $lsof_sudo_required_on = 1; # to get process names
my $geolite_file = "$resource_dir/GeoLite2-ASN-Blocks-IPv4.csv";
my $show_close_button = 1; # Set to 1 to make the top right corner a hidden close button
my $debug = 0; # turn off debugging

# --- Global State ---
my $state = {
    current_points => [],
    history_points => {},
    show_history   => 0,
    map_width      => 1000,
    map_height     => 500,
    dot_diameter   => 8,
    default_dot_color => [1, 0.2, 0.2, 0.8], # red (for unmatched ports)
    text_color_2        => [0.1, 0.9, 0.2, 0.9], # blue
    text_color        => [1, 1, 1, 0.9], # white
    do_asnlookup   => 0,
    ip2asn => {},
    port_colors => [], # Will be populated from connmapperlrc
    show_text_globally => 1,
    right_click_circle => undef,
    color_key_areas    => [],
    status_message => "",
    status_message_timer_id => undef,
    show_help_text => 1,
    help_text_area => undef,
};

my $window;
my $drawing_area;
my $ip2asn_lookup_enabled = 0;
my $ip2asn_script_path = "";

#======================================================================
# Status Message Handling
#======================================================================
sub set_status_message {
    my ($message) = @_;
    print "$message\n";
    $state->{status_message} = $message;
    if (defined $state->{status_message_timer_id}) {
        Glib::Source->remove($state->{status_message_timer_id});
        $state->{status_message_timer_id} = undef;
    }
    $state->{status_message_timer_id} = Glib::Timeout->add(2000, sub {
        $state->{status_message} = "";
        $state->{status_message_timer_id} = undef;
        $drawing_area->queue_draw() if $drawing_area;
        return 0;
    });
    $drawing_area->queue_draw() if $drawing_area;
}


#======================================================================
# Configuration Loading & History I/O
#======================================================================

sub save_history_points {
    eval {
        store($state->{history_points}, $history_file);
    };
    if ($@) {
        set_status_message("Error saving history: $@");
        return;
    }
    set_status_message("History saved to $history_file");
}

sub load_history_points {
    unless (-e $history_file) {
        set_status_message("History file not found: $history_file");
        return;
    }
    my $loaded_history;
    eval {
        $loaded_history = retrieve($history_file);
    };
    if ($@) {
        set_status_message("Error loading history: $@");
        return;
    }
    unless (ref($loaded_history) eq 'HASH') {
        set_status_message("Error: History file is corrupt or not valid.");
        return;
    }
    $state->{history_points} = $loaded_history;
    set_status_message("History loaded from $history_file");
    $drawing_area->queue_draw();
}

sub load_port_colors_from_config {
    my ($file) = @_;
    unless (-e $file) {
        warn "Configuration file not found: $file. Using default colors only.\n";
        return;
    }
    my $csv = Text::CSV->new({ binary => 1, auto_diag => 1, allow_loose_quotes => 1 });
    open(my $fh, "<:encoding(utf8)", $file) or do {
        warn "Could not open config file '$file': $!. Using defaults.\n";
        return;
    };
    my @loaded_colors;
    while (my $row = $csv->getline($fh)) {
        next if $row->[0] =~ /^\s*#/;
        next unless @$row == 8;
        my ($label, $type, $port, $r, $g, $b, $a, $show_text) = @$row;
        unless (defined $label && ($type eq 'local' || $type eq 'peer') && $port =~ /^\d+$/ && defined $show_text) {
            warn "Skipping invalid line in config file: " . join(',', @$row) . "\n";
            next;
        }
        push @loaded_colors, {
            label => $label, type => $type, port => int($port),
            color => [$r, $g, $b, $a], show_text => int($show_text),
        };
    }
    close $fh;
    $state->{port_colors} = \@loaded_colors;
    print "Loaded " . scalar(@loaded_colors) . " port color rules from $config_file.\n";
}

#======================================================================
# IP Geolocation and Coordinate Functions
#======================================================================
sub ip_to_decimal {
    my ($ip_str) = @_;
    my @parts = split /\./, $ip_str;
    return 0 unless @parts == 4;
    return ($parts[0] << 24) + ($parts[1] << 16) + ($parts[2] << 8) + $parts[3];
}

sub find_location_on_disk {
    my ($fh, $ip_decimal, $file_size) = @_;
    my ($low_pos, $high_pos) = (0, $file_size);
    for (1..30) {
        last if ($high_pos - $low_pos) < 2;
        my $mid_pos = int($low_pos + ($high_pos - $low_pos) / 2);
        seek($fh, $mid_pos, 0); <$fh>;
        my $line = <$fh>;
        return undef unless defined $line;
        my ($start, $end, $lat, $lon) = split ',', $line;
        next unless defined $start and defined $end;
        if ($ip_decimal >= $start && $ip_decimal <= $end) {
            return { lat => $lat, lon => $lon };
        }
        $ip_decimal < $start ? ($high_pos = $mid_pos) : ($low_pos = $mid_pos);
    }
    return undef;
}

sub get_connections {
    my $output = qx(ss -t -n -4 state established);
    my @lines = split /\n/, $output;
    shift @lines;
    my @connections;
    for my $line (@lines) {
        my @fields = split /\s+/, $line;
        next unless @fields >= 4;
        my ($local_ip, $local_port) = ($fields[2] =~ /^(.+):(\d+)$/);
        my ($peer_ip, $peer_port) = ($fields[3] =~ /^(.+):(\d+)$/);
        if ($local_ip and $local_port and $peer_ip and $peer_port) {
            push @connections, {
                local_ip => $local_ip, local_port => $local_port,
                ip => $peer_ip, port => $peer_port,
            };
        }
    }
    return @connections;
}

sub latlon_to_xy {
    my ($latitude, $longitude) = @_;
    my ($width, $height) = ($state->{map_width}, $state->{map_height});
    my ($xOffset, $yOffset) = (-29.0, 76.0);
    my $x = ($longitude + 180) * ($width / 360) + $xOffset;
    $x -= (($longitude/180) * 15) if $longitude > 90;
    my $latRad = $latitude * PI / 180;
    my $mercN = log(tan((PI / 4) + ($latRad / 2)));
    my $y = ($height / 2) - ($width * $mercN / (2 * PI)) + $yOffset;
    return ($x, $y);
}

sub get_app_for_ipv4 {
    my ($ipv4_address) = @_;
    return unless $lsof_sudo_required_on;
    open(my $lsof_pipe, "-|", 'lsof -R -P -i -n') or return;
    while (my $line = <$lsof_pipe>) {
        if ($line =~ /\b\Q$ipv4_address\E\b/) {
            my ($command) = split /\s+/, $line;
            close $lsof_pipe;
            return $command;
        }
    }
    close $lsof_pipe;
    return;
}

sub find_ip_in_geolite_asn {
    my ($geolite_csv_file, $ip_to_check) = @_;
    my $input_ip = Net::IP->new($ip_to_check);
    return unless $input_ip && $input_ip->version() == 4;
    my $input_ip_num = $input_ip->intip();
    open(my $fh, '<', $geolite_csv_file) or return;
    my $file_size = -s $fh;
    my ($low, $high) = (0, $file_size);
    my $found_line;
    $low = length(scalar <$fh>);
    while ($low <= $high) {
        my $mid = int(($low + $high) / 2);
        seek($fh, $mid, SEEK_SET); <$fh> if $mid > 0;
        my $line = <$fh>;
        next unless defined $line && $line =~ /\S/;
        my ($network_cidr) = split /,/, $line, 2;
        my $network_ip = Net::IP->new($network_cidr);
        unless ($network_ip) { $high = $mid -1; next; }
        my $start_ip_num = $network_ip->intip();
        my $end_ip_num   = $start_ip_num + ($network_ip->size() - 1);
        if ($input_ip_num >= $start_ip_num && $input_ip_num <= $end_ip_num) {
            $found_line = $line; last;
        } elsif ($input_ip_num < $start_ip_num) {
            $high = $mid - 1;
        } else { $low = $mid + 1; }
    }
    close $fh;
    return unless defined $found_line;
    my @fields = split /,/, $found_line;
    return { network => $fields[0], asn => $fields[1], organization => $fields[2] || "N/A" };
}

#======================================================================
# Subroutine to generate the full text label for a point
#======================================================================
sub get_point_label_text {
    my ($point) = @_;
    my $programname = get_app_for_ipv4($point->{ip}) // "";
    if ($state->{ip2asn}->{$point->{ip}}) {
        return "$programname:$point->{local_port} -> $point->{ip}:$point->{port} - $state->{ip2asn}->{$point->{ip}}";
    } else {
        my $asn_hash = find_ip_in_geolite_asn($geolite_file, $point->{ip});
        if ($asn_hash) {
            my $asn_org_str = "$asn_hash->{asn} - $asn_hash->{organization}";
            $state->{ip2asn}->{$point->{ip}} = $asn_org_str;
            return "$programname:$point->{local_port} -> $point->{ip}:$point->{port} - $asn_org_str";
        } else {
            return "$programname:$point->{local_port} -> $point->{ip}:$point->{port}";
        }
    }
}

#======================================================================
# GTK, Drawing, and Timer Functions
#======================================================================
sub update_connections_and_redraw {
    my ($drawing_area) = @_;
    open my $db_fh, '<', $ipv4_db_path or return 1;
    my $db_size = -s $db_fh;
    my @connections = get_connections();
    my @new_points;
    for my $conn (@connections) {
        my $ip_decimal = ip_to_decimal($conn->{ip});
        next unless $ip_decimal;
        my $location = find_location_on_disk($db_fh, $ip_decimal, $db_size);
        if ($location) {
            my $key = "$location->{lat},$location->{lon}";
            
            unless (exists $state->{history_points}->{$key}) {
                $state->{history_points}->{$key} = { count => 0, show_text_seen => 0 };
            }
            $state->{history_points}->{$key}->{count}++;

            if (!$state->{history_points}->{$key}->{show_text_seen}) {
                my $is_other_connection = 1;

                for my $rule (@{$state->{port_colors}}) {
                    if ( ($rule->{type} eq 'local' && $conn->{local_port} == $rule->{port}) ||
                         ($rule->{type} eq 'peer'  && $conn->{port} == $rule->{port}) )
                    {
                        $is_other_connection = 0;
                        if ($rule->{show_text}) {
                            $state->{history_points}->{$key}->{show_text_seen} = 1;
                        }
                        last;
                    }
                }

                if ($is_other_connection) {
                    $state->{history_points}->{$key}->{show_text_seen} = 1;
                }
            }

            push @new_points, { ip => $conn->{ip}, port => $conn->{port}, lat  => $location->{lat}, lon  => $location->{lon},
                                local_ip => $conn->{local_ip}, local_port => $conn->{local_port}, };
        }
    }
    close $db_fh;
    $state->{current_points} = \@new_points;
    if ($debug) {
        print "--- DEBUG: History Points ---\n";
        foreach my $key (sort keys %{$state->{history_points}}) {
            my $point_data = $state->{history_points}->{$key};
            printf "  %-30s | Count: %-5d | Show Text Seen: %d\n",
                   "Key: $key", $point_data->{count}, $point_data->{show_text_seen};
        }
        print "---------------------------\n\n";
    }
    $drawing_area->queue_draw();
    return 1;
}

sub draw_color_key {
    my ($cr) = @_;
    my @key_items;
    my %seen_labels;

    for my $rule (@{$state->{port_colors}}) {
        my $label = $rule->{label};
        unless ($seen_labels{$label}) {
            push @key_items, { label => $label, color => $rule->{color} };
            $seen_labels{$label} = 1;
        }
    }
    push @key_items, { label => "Other", color => $state->{default_dot_color} };

    my $x_start = 15;
    my $line_height = 20;
    my $y_start = $state->{map_height} - (scalar(@key_items) * $line_height) - 10;
    my $box_size = 10;

    $cr->select_font_face('Sans', 'normal', 'bold');
    $cr->set_font_size(10);

    my $current_y = $y_start;
    my @key_areas;

    for my $item (@key_items) {
        push @key_areas, {
            x => $x_start, y => $current_y, w => $box_size, h => $box_size,
            label => $item->{label},
        };

        $cr->set_source_rgba(@{$item->{color}});
        $cr->rectangle($x_start, $current_y, $box_size, $box_size);
        $cr->fill;

        $cr->set_source_rgba(@{$state->{text_color}});
        $cr->move_to($x_start + 18, $current_y + 10);
        $cr->show_text($item->{label});

        $current_y += $line_height;
    }
    $state->{color_key_areas} = \@key_areas;
}

sub on_draw {
    my ($widget, $cr) = @_;
    my $pixbuf = $widget->{_pixbuf_};
    return 1 unless $pixbuf;

    Gtk3::Gdk::cairo_set_source_pixbuf($cr, $pixbuf, 0, 0);
    $cr->paint;

    if ($state->{show_history}) {
        my $base_history_radius = $state->{dot_diameter} / 3;
        my $max_history_radius = $state->{dot_diameter} * 1.5;
        my $history_color = [1.0, 1.0, 1.0, 0.6];
        
        $cr->set_source_rgba(@{$history_color});

        for my $key (keys %{$state->{history_points}}) {
            my $history_entry = $state->{history_points}->{$key};
            next unless $history_entry->{show_text_seen};
            my ($lat, $lon) = split ',', $key;
            next unless defined $lat && defined $lon;
            my $count = $history_entry->{count};
            my $scaled_radius = $base_history_radius + log($count);
            $scaled_radius = $max_history_radius if $scaled_radius > $max_history_radius;
            my ($x, $y) = latlon_to_xy($lat, $lon);
            $cr->arc($x, $y, $scaled_radius, 0, 2 * PI);
            $cr->fill;
        }
    }

    my $radius = $state->{dot_diameter} / 2;
    for my $point (@{$state->{current_points}}) {
        my ($x, $y) = latlon_to_xy($point->{lat}, $point->{lon});
        my ($dot_color, $show_label) = ($state->{default_dot_color}, 1);
        for my $rule (@{$state->{port_colors}}) {
            if (($rule->{type} eq 'local' && $point->{local_port} == $rule->{port}) ||
                ($rule->{type} eq 'peer' && $point->{port} == $rule->{port})) {
                $dot_color = $rule->{color};
                $show_label = $rule->{show_text};
                last;
            }
        }
        $cr->set_source_rgba(@{$dot_color});
        $cr->arc($x, $y, $radius, 0, 2 * PI); $cr->fill;
        if ($show_label && $state->{show_text_globally}) {
            $cr->set_source_rgba(@{$state->{text_color}});
            $cr->select_font_face('Sans', 'normal', 'normal');
            $cr->set_font_size(8);
            $cr->move_to($x + $radius + 2, $y + $radius / 2);
            my $label = get_point_label_text($point);
            $cr->show_text($label);
        }
    }
    draw_color_key($cr);

    if (defined $state->{right_click_circle}) {
        my $circle = $state->{right_click_circle};
        $cr->new_path; 
        $cr->set_source_rgba(1, 1, 1, 0.9);
        $cr->set_line_width(1.5);
        $cr->arc($circle->{x}, $circle->{y}, $circle->{radius}, 0, 2 * PI);
        $cr->stroke;
    }

    if (defined $state->{status_message} && $state->{status_message} ne "") {
        my $message = $state->{status_message};
        $cr->select_font_face('Sans', 'normal', 'bold');
        $cr->set_font_size(14);
        my $extents = $cr->text_extents($message);
        my $padding = 10;
        my $rect_w = $extents->{width} + (2 * $padding);
        my $rect_h = $extents->{height} + (2 * $padding);
        my $rect_x = ($state->{map_width} - $rect_w) / 2;
        my $rect_y = $state->{map_height} - $rect_h - 5;
        $cr->set_source_rgba(0, 0, 0, 0.7);
        $cr->rectangle($rect_x, $rect_y, $rect_w, $rect_h);
        $cr->fill;
        my $text_x = $rect_x + $padding;
        my $text_y = $rect_y + $padding + $extents->{y_bearing} * -1;
        $cr->move_to($text_x, $text_y);
        $cr->set_source_rgba(@{$state->{text_color}});
        $cr->show_text($message);
    }

    if ($state->{show_help_text}) {
        my $help_text = "press: t, h, s, l, esc, or click color key squares, or right click to select [click here to close help]";
        $cr->select_font_face('Sans', 'normal', 'normal');
        $cr->set_font_size(9);
        my $help_extents = $cr->text_extents($help_text);
        my $help_padding = 5;
        my $help_x = $state->{map_width} - $help_extents->{width} - $help_padding;
        my $help_y = $state->{map_height} - $help_padding;
        
        $state->{help_text_area} = {
            x => $help_x,
            y => $help_y - $help_extents->{height},
            w => $help_extents->{width},
            h => $help_extents->{height},
        };

        $cr->set_source_rgba(@{$state->{text_color}});
        $cr->move_to($help_x, $help_y);
        $cr->show_text($help_text);
    }

    return 1;
}

#======================================================================
# Right-click popup logic
#======================================================================
sub create_info_popup {
    my ($info_lines_ref) = @_;
    my $popup = Gtk3::Window->new('toplevel');
    $popup->set_title("Nearby Connections");
    $popup->set_default_size(450, 250);
    $popup->set_position('center-on-parent');
    $popup->set_transient_for($window);
    $popup->set_destroy_with_parent(1);
    $popup->set_border_width(5);

    my $main_vbox = Gtk3::Box->new('vertical', 5);
    $popup->add($main_vbox);

    if ($ip2asn_lookup_enabled) {
        my $lookup_hbox = Gtk3::Box->new('horizontal', 5);
        my $ip_label = Gtk3::Label->new("IP:");
        my $ip_entry = Gtk3::Entry->new();
        my $lookup_button = Gtk3::Button->new_with_label("Look-up");

        $lookup_hbox->pack_start($ip_label, 0, 0, 5);
        $lookup_hbox->pack_start($ip_entry, 1, 1, 0);
        $lookup_hbox->pack_start($lookup_button, 0, 0, 5);

        $main_vbox->pack_start($lookup_hbox, 0, 0, 5);

        $lookup_button->signal_connect(clicked => sub {
            my $ip = $ip_entry->get_text();
            $ip =~ s/^\s+|\s+$//g;

            unless ($ip =~ /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/ || Net::IP->new($ip)) {
                warn "Invalid IP address format entered: '$ip'\n";
                return;
            }
            
            my $command = "perl $ip2asn_script_path \"$ip\"";
            my $output = qx($command);

            my $output_popup = Gtk3::Window->new('toplevel');
            $output_popup->set_title("Lookup: $ip");
            $output_popup->set_default_size(400, 300);
            $output_popup->set_position('center-on-parent');
            $output_popup->set_transient_for($popup);
            $output_popup->set_destroy_with_parent(1);
            
            my $output_sw = Gtk3::ScrolledWindow->new(undef, undef);
            $output_sw->set_policy('automatic', 'automatic');
            
            my $output_tv = Gtk3::TextView->new();
            $output_tv->set_editable(0);
            $output_tv->get_buffer->set_text($output || "No output from script for $ip.");
            
            $output_sw->add($output_tv);
            $output_popup->add($output_sw);
            $output_popup->show_all;
        });
    }

    my $scrolled_window = Gtk3::ScrolledWindow->new(undef, undef);
    $scrolled_window->set_policy('automatic', 'automatic');
    $scrolled_window->set_shadow_type('in');
    
    my $textview = Gtk3::TextView->new;
    $textview->set_editable(0);
    $textview->set_cursor_visible(0);
    $textview->set_wrap_mode('word');
    my $buffer = $textview->get_buffer;
    my $text = join("\n", @$info_lines_ref);
    $buffer->set_text($text);

    $scrolled_window->add($textview);
    $main_vbox->pack_start($scrolled_window, 1, 1, 0);
    
    $popup->show_all;
}


sub handle_right_click {
    my ($click_x, $click_y) = @_;
    my @info_strings;
    my $radius = 15;
    my $radius_squared = $radius * $radius;   
    for my $point (@{$state->{current_points}}) {
        my ($px, $py) = latlon_to_xy($point->{lat}, $point->{lon});
        my $dist_squared = ($px - $click_x)**2 + ($py - $click_y)**2;
        if ($dist_squared <= $radius_squared) {
            if ($state->{show_text_globally}) {
                my $full_label = get_point_label_text($point);
                push @info_strings, "$full_label GPS: $point->{lat}, $point->{lon}";
            } else {
                push @info_strings, "IP: $point->{ip} GPS: $point->{lat}, $point->{lon}";
            }
        }
    }
    create_info_popup(\@info_strings) if @info_strings;

    $state->{right_click_circle} = { x => $click_x, y => $click_y, radius => $radius };
    $drawing_area->queue_draw();

    Glib::Timeout->add(500, sub {
        $state->{right_click_circle} = undef;
        $drawing_area->queue_draw();
        return 0;
    });
}

#======================================================================
# Main Execution Block
#======================================================================

$ip2asn_script_path = "ip2asn.pl";
if (-e $ip2asn_script_path && -x $ip2asn_script_path) {
    $ip2asn_lookup_enabled = 1;
    print "Found executable '$ip2asn_script_path' in current directory, lookup feature is enabled.\n";
} else {
    print "Did not find executable 'ip2asn.pl' in current directory, lookup feature is disabled.\n";
}

load_port_colors_from_config($config_file);

my $pixbuf;
eval { $pixbuf = Gtk3::Gdk::Pixbuf->new_from_file_at_size($image_path, $state->{map_width}, $state->{map_height}); };
die "Error loading image '$image_path': $@" if $@;

$window = Gtk3::Window->new('toplevel');
$window->set_title("connmap-gtk3");
$window->set_default_size($state->{map_width}, $state->{map_height});
$window->set_position('center');
$window->set_decorated(0);
$window->set_app_paintable(1);

my $screen = $window->get_screen;
my $visual = $screen->get_rgba_visual;
if ($visual) {
    $window->set_visual($visual);
} else {
    print "DEBUG: No RGBA visual available. Transparency may not work.\n";
}

my $fixed_container = Gtk3::Fixed->new;
$drawing_area = Gtk3::DrawingArea->new;
$drawing_area->{_pixbuf_} = $pixbuf;
$drawing_area->set_size_request($state->{map_width}, $state->{map_height});
$fixed_container->put($drawing_area, 0, 0);

if ($show_close_button) {
    my $close_button = Gtk3::Button->new_from_icon_name('window-close-symbolic', 'button');
    $close_button->set_relief('none');
    
    my ($min, $req) = $close_button->get_preferred_size;
    $fixed_container->put($close_button, $state->{map_width} - $req->width() - 5, 5);
    
    $close_button->signal_connect(clicked => sub { Gtk3->main_quit; });
}

$window->add($fixed_container);
$drawing_area->signal_connect('draw' => \&on_draw);
$window->signal_connect(destroy => sub { Gtk3->main_quit; });

$window->signal_connect('key-press-event' => sub {
    my ($widget, $event) = @_;
    my $keyname = Gtk3::Gdk::keyval_name($event->get_keyval);
    if ($keyname eq 'h') {
        $state->{show_history} = !$state->{show_history};
        set_status_message("History view: " . ($state->{show_history} ? "ON" : "OFF"));
    } elsif ($keyname eq 't') {
        $state->{show_text_globally} = !$state->{show_text_globally};
        set_status_message("Global text labels: " . ($state->{show_text_globally} ? "ON" : "OFF"));
    } elsif ($keyname eq 's') {
        save_history_points();
    } elsif ($keyname eq 'l') {
        load_history_points();
    } elsif ($keyname eq 'Escape') {
        set_status_message("Exiting...");
        Gtk3->main_quit;
    }
});

my $drag_info = {};
$window->add_events(['button-press-mask', 'button1-motion-mask', 'button-release-mask', 'key-press-mask']);
$window->signal_connect('button-press-event' => sub {
    my ($widget, $event) = @_;
    my ($click_x, $click_y) = $event->get_coords;

    if ($event->button == 1) {
        for my $area (@{$state->{color_key_areas}}) {
            if ($click_x >= $area->{x} && $click_x <= ($area->{x} + $area->{w}) &&
                $click_y >= $area->{y} && $click_y <= ($area->{y} + $area->{h}))
            {
                my $label_clicked = $area->{label};
                if ($label_clicked eq 'Other') {
                    set_status_message("'Other' is not a toggleable rule");
                    return 1;
                }
                my $new_state;
                for my $rule (@{$state->{port_colors}}) {
                    if ($rule->{label} eq $label_clicked) {
                        $rule->{show_text} = !$rule->{show_text};
                        $new_state = $rule->{show_text};
                    }
                }
                set_status_message(sprintf("Rule '%s' text: %s", $label_clicked, ($new_state ? "ON" : "OFF")));
                return 1;
            }
        }

        if ($state->{show_help_text} && defined $state->{help_text_area}) {
            my $area = $state->{help_text_area};
            # CORRECTED: Typo fixed. Use hash-style access $area->{y} instead of method-style $area->y
            if ($click_x >= $area->{x} && $click_x <= ($area->{x} + $area->{w}) &&
                $click_y >= $area->{y} && $click_y <= ($area->{y} + $area->{h}))
            {
                $state->{show_help_text} = 0;
                $drawing_area->queue_draw();
                return 1;
            }
        }

        my ($root_x, $root_y) = $event->get_root_coords;
        my ($win_x, $win_y) = $widget->get_position;
        $drag_info->{x} = $root_x - $win_x;
        $drag_info->{y} = $root_y - $win_y;
        return 1;
    }
    elsif ($event->button == 3) {
        handle_right_click($click_x, $click_y);
        return 1;
    }
    return 0;
});
$window->signal_connect('motion-notify-event' => sub {
    my ($widget, $event) = @_;
    if (exists $drag_info->{x}) {
        my ($root_x, $root_y) = $event->get_root_coords;
        $widget->move($root_x - $drag_info->{x}, $root_y - $drag_info->{y});
    }
});
$window->signal_connect('button-release-event' => sub {
    $drag_info = {};
});


Glib::Timeout->add(1000, \&update_connections_and_redraw, $drawing_area);
$window->show_all;
update_connections_and_redraw($drawing_area);
Gtk3->main;
exit 0;
