#!/usr/bin/env perl
#ABSTRACT: Interactive TUI for viewing and managing SLURM jobs
#PODNAME: viewjobs

use v5.12;
use warnings;
use utf8;
use FindBin qw($RealBin);
use Data::Dumper;
use Getopt::Long qw(:config no_ignore_case bundling);
$Data::Dumper::Sortkeys = 1;

if (-e "$RealBin/../dist.ini") {
    say STDERR "[dev mode] Using local lib" if ($ENV{"DEBUG"});
    use lib "$RealBin/../lib";
}

use NBI::Slurm;

# ── CLI arguments ─────────────────────────────────────────────────────────────
# Parsed before the terminal check so --help works without a TTY.
my $_default_user = $ENV{USER} // 'unknown';
my %cli_opts = (
    user    => $_default_user,
    name    => '.+',
    status  => '.+',
    running => 0,
    pending => 0,
);

GetOptions(
    'u|user=s'   => \$cli_opts{user},
    'n|name=s'   => \$cli_opts{name},
    's|status=s' => \$cli_opts{status},
    'r|running'  => \$cli_opts{running},
    'p|pending'  => \$cli_opts{pending},
    'h|help'     => sub { usage(); exit 0 },
) or do { usage(); exit 1 };

$cli_opts{user} = '.+' if lc($cli_opts{user}) eq 'all';

# -r / -p override -s (last one wins if both given)
$cli_opts{status} = '^RUNNING' if $cli_opts{running};
$cli_opts{status} = '^PENDING' if $cli_opts{pending};

# Check if STDIN is a terminal
die "Error: Not running in a terminal\n" unless -t STDIN;

# Check SLURM availability
if (not NBI::Slurm::has_squeue()) {
    die "Error: squeue not found. Are you in the cluster?\n";
}

# Constants for Ctrl keys
use constant {
    CTRL_C => 3,
    CTRL_D => 4,
    CTRL_H => 8,
    CTRL_L => 12,
    CTRL_R => 18,
};

# ANSI escape sequences
my $ANSI = {
    clear_screen => "\e[2J",
    move_home => "\e[H",
    move_to => sub { my ($row, $col) = @_; "\e[${row};${col}H" },
    hide_cursor => "\e[?25l",
    show_cursor => "\e[?25h",
    clear_line => "\e[2K",
    save_cursor => "\e7",
    restore_cursor => "\e8",
};

# Color scheme (always with bg and fg)
my $COLORS = {
    normal => "\e[0;37;40m",             # white on black
    active_col => "\e[0;1;37;40m",       # bold white on black
    header => "\e[0;1;37;44m",           # bold white on blue
    active_row => "\e[0;30;47m",         # black on white
    active_cell => "\e[0;1;30;43m",      # bold black on yellow
    selected => "\e[0;37;45m",           # white on magenta
    selected_active => "\e[0;1;37;46m",  # bold white on cyan (selected + active)
    status_bar => "\e[0;37;44m",         # white on blue
    reset => "\e[0;37;40m",              # back to normal
    # Cell-specific semantic colors (applied only to unselected/inactive cells)
    cell_running => "\e[0;32;40m",       # green on black  (State=RUNNING)
    cell_pending => "\e[0;33;40m",       # yellow on black (State=PENDING)
    cell_error   => "\e[0;31;40m",       # red on black    (error State/Reason)
    cell_time_zero => "\e[0;34;40m",     # blue on black   (Time=0 / no time)
    # Detail view
    detail_key => "\e[0;36;40m",         # cyan on black   (key column in detail view)
    # Filter input prompt
    filter_prompt => "\e[0;30;43m",      # black on yellow (filter bar)
    filter_active => "\e[0;1;33;40m",    # bold yellow on black (active-filter indicator)
    # Column separator
    col_sep => "\e[0;90;40m",            # dark-gray on black (between columns)
};

# Initialize state
my $state = {
    # Job data
    jobs => [],
    columns => [
        {name => 'JobID', width => 10, visible => 1, sort => 'none'},
        {name => 'User', width => 12, visible => 1, sort => 'none'},
        {name => 'Queue', width => 12, visible => 1, sort => 'none'},
        {name => 'Name', width => 20, visible => 1, sort => 'none'},
        {name => 'State', width => 10, visible => 1, sort => 'none'},
        {name => 'Time', width => 10, visible => 1, sort => 'none'},
        {name => 'TotalTime', width => 10, visible => 1, sort => 'none'},
        {name => 'NodeList', width => 15, visible => 1, sort => 'none'},
        {name => 'CPUS', width => 6, visible => 1, sort => 'none'},
        {name => 'Memory', width => 8, visible => 1, sort => 'none'},
        {name => 'Reason', width => 15, visible => 1, sort => 'none'},
    ],

    # Navigation
    active_row => 0,
    active_col => 0,
    scroll_offset => 0,
    scroll_col_offset => 0,

    # Selection
    selected => {},

    # Display
    term_height => 24,
    term_width => 80,
    header_rows => 1,
    footer_rows => 1,

    # UI state
    mode => 'normal',
    message => '',
    running => 1,

    # CLI-supplied initial filters (re-applied on every refresh)
    cli_opts => \%cli_opts,

    # Filter state
    all_jobs     => [],   # immutable master copy from last load
    filters      => [],   # [{col_name, pattern, negate, label}, ...]
    input_mode   => '',   # 'filter_include' | 'filter_negate' | ''
    input_buffer => '',   # text typed so far in filter prompt

    # Detail view state
    detail_fields => [],
    detail_scroll  => 0,
    detail_jobid   => '',
};

# Package variable for terminal state restoration
our $original_stty;

# Main entry point
sub main {
    # Initialize terminal size
    ($state->{term_height}, $state->{term_width}) = get_terminal_size();

    # Load initial job data
    load_jobs($state);

    # Setup terminal
    $original_stty = `stty -g 2>/dev/null`;
    chomp $original_stty;
    system('stty raw -echo') == 0 or die "Failed to set raw mode\n";

    # Enable output autoflush
    $| = 1;
    binmode(STDOUT, ':utf8');
    binmode(STDERR, ':utf8');

    # Comprehensive signal handling
    $SIG{INT} = sub { cleanup(); exit 0 };
    $SIG{TERM} = sub { cleanup(); exit 0 };
    $SIG{QUIT} = sub { cleanup(); exit 0 };
    $SIG{TSTP} = sub { cleanup(); exit 0 };
    $SIG{WINCH} = 'IGNORE';

    END { cleanup() if $original_stty }

    # Main loop
    while ($state->{running}) {
        render_screen($state);
        handle_input($state);
    }

    cleanup();
}

# Terminal control functions
sub get_terminal_size {
    my $height = `tput lines 2>/dev/null`;
    my $width = `tput cols 2>/dev/null`;

    chomp($height) if defined $height;
    chomp($width) if defined $width;

    $height = 24 unless ($height && $height =~ /^\d+$/ && $height > 10);
    $width = 80 unless ($width && $width =~ /^\d+$/ && $width > 40);

    return ($height, $width);
}

sub cleanup {
    print $ANSI->{clear_screen};
    print $ANSI->{move_home};
    print $ANSI->{show_cursor};
    print $COLORS->{reset};
    system("stty $original_stty") if $original_stty;
}

# Keyboard input
sub read_key {
    my $key;
    sysread(STDIN, $key, 1) or return '';

    # Handle escape sequences with timeout
    if ($key eq "\e") {
        my $seq = '';

        # Use select() with 100ms timeout to detect escape sequences
        my $rin = '';
        vec($rin, fileno(STDIN), 1) = 1;
        my $nfound = select(my $rout = $rin, undef, undef, 0.1);

        if ($nfound) {
            # Read up to 10 bytes for multi-byte sequences
            sysread(STDIN, $seq, 10);

            # Standard arrow keys
            return "UP" if $seq =~ /^\[A/;
            return "DOWN" if $seq =~ /^\[B/;
            return "RIGHT" if $seq =~ /^\[C/;
            return "LEFT" if $seq =~ /^\[D/;

            # Page Up/Down
            return "PGUP" if $seq =~ /^\[5~/;
            return "PGDN" if $seq =~ /^\[6~/;

            # Home/End (multiple variants)
            return "HOME" if $seq =~ /^\[(H|1~)/;
            return "END" if $seq =~ /^\[(F|4~)/;
        }

        # Just ESC alone
        return "ESC";
    }

    # Enter / Return
    return "ENTER" if ord($key) == 13 || ord($key) == 10;

    # DEL / Backspace (0x7F)
    return "DEL" if ord($key) == 127;

    # Handle Ctrl keys
    return "CTRL_C" if ord($key) == CTRL_C;
    return "CTRL_D" if ord($key) == CTRL_D;
    return "CTRL_H" if ord($key) == CTRL_H;
    return "CTRL_L" if ord($key) == CTRL_L;
    return "CTRL_R" if ord($key) == CTRL_R;

    return $key;
}

# Data loading
sub load_jobs {
    my $state = shift;

    # Reuse getjobs() from lsjobs with error handling
    my $jobs_hash = eval { getjobs() };
    if ($@ || !defined $jobs_hash || ref($jobs_hash) ne 'HASH') {
        $state->{message} = "Error loading jobs: $@";
        $state->{jobs} = [];
        return;
    }

    # Convert to array for easier indexing
    my @jobs = ();
    for my $jobid (sort keys %$jobs_hash) {
        push @jobs, $jobs_hash->{$jobid};
    }

    # Store immutable master copy; working copy starts identical
    $state->{all_jobs}      = \@jobs;
    $state->{jobs}          = [@jobs];   # safety fallback if apply_filters skips

    # Clear interactive filters and selections on every (re)load
    $state->{filters}       = [];
    $state->{selected}      = {};
    $state->{active_row}    = 0;
    $state->{scroll_offset} = 0;

    # Re-seed CLI-derived filters (persist across refreshes)
    push_cli_filters($state);

    # Apply them (also clamps active_row, re-sorts, adjusts scroll)
    apply_filters($state);
}

sub getjobs {
    # Create an anonymous hash, and return it
    my $jobs = {};
    # Use specific format for efficiency - only get fields we need
    my $cmd = q(squeue --format='%i|%u|%P|%j|%T|%M|%l|%N|%C|%m|%r');
    my @output = `$cmd 2>&1`;

    # Check if command failed
    if ($? != 0) {
        die "Failed to execute squeue command\n";
    }

    my $c = 0;
    my @header = ();
    for my $line (@output) {
        chomp $line;
        next if $line =~ /^\s*$/;

        my @fields = split(/\|/, $line);
        $c++;
        if ($c == 1) {
            # Field names
            for my $field (@fields) {
                push(@header, stripchars($field));
            }
        } else {
            # Job info
            my $job = {};
            if (scalar(@fields) != scalar(@header)) {
                next;  # Skip malformed lines
            }
            for my $i (0..$#header) {
                $job->{"$header[$i]"} = $fields[$i] if (not defined $job->{"$header[$i]"});
            }

            # Ensure we have a valid JOBID before adding
            if (defined $job->{JOBID} && $job->{JOBID} ne '') {
                $jobs->{$job->{JOBID}} = $job;
            }
        }
    }

    return $jobs;
}

sub stripchars {
    my $string = shift;
    $string =~ s/[^A-Za-z0-9]/_/g;
    return $string;
}

# Translate CLI options into filter objects and push them onto the filter stack.
# Called at the end of load_jobs so they re-apply after every refresh.
sub push_cli_filters {
    my $state = shift;
    my $opts  = $state->{cli_opts} or return;

    # User filter — treat the value as a full-match regex (like lsjobs does)
    if ($opts->{user} ne '.+') {
        push @{$state->{filters}}, {
            col_name => 'User',
            pattern  => '^(?:' . $opts->{user} . ')$',
            negate   => 0,
        };
    }

    # Name filter — partial regex, same as lsjobs
    if ($opts->{name} ne '.+') {
        push @{$state->{filters}}, {
            col_name => 'Name',
            pattern  => $opts->{name},
            negate   => 0,
        };
    }

    # Status filter — -r/-p already normalised into status by GetOptions block
    if ($opts->{status} ne '.+') {
        push @{$state->{filters}}, {
            col_name => 'State',
            pattern  => $opts->{status},
            negate   => 0,
        };
    }
}

# Screen rendering
sub render_screen {
    my $state = shift;

    if ($state->{mode} eq 'detail') {
        render_detail_screen($state);
        return;
    }

    print $ANSI->{hide_cursor};
    print $ANSI->{clear_screen};
    print $ANSI->{move_home};

    render_header($state);
    render_data_rows($state);
    render_footer($state);

    print $ANSI->{show_cursor};
}

sub render_header {
    my $state = shift;
    my $line = '';

    my $col_pos = 0;
    my $vis_idx = 0;
    for my $i (0..$#{$state->{columns}}) {
        my $col = $state->{columns}[$i];
        next unless $col->{visible};

        # Column separator before every column except the first
        if ($vis_idx > 0) {
            last if $state->{term_width} - $col_pos <= 0;
            $line .= $COLORS->{col_sep} . '|';
            $col_pos += 1;
        }

        my $avail = $state->{term_width} - $col_pos;
        last if $avail <= 0;

        my $width = $col->{width};
        $width = $avail if $width > $avail;
        my $name = $col->{name};

        # Highlight active column
        my $color = ($i == $state->{active_col})
            ? $COLORS->{active_cell}
            : $COLORS->{header};

        # Add sort indicator
        my $sort_icon = $col->{sort} eq 'asc' ? '↑' :
                       $col->{sort} eq 'desc' ? '↓' : ' ';

        $line .= $color . sprintf("%-*s", $width, substr("$name $sort_icon", 0, $width));
        $col_pos += $width;
        $vis_idx++;
    }

    # Fill rest of line with header color
    my $remaining = $state->{term_width} - $col_pos;
    $line .= $COLORS->{header} . (' ' x $remaining) if $remaining > 0;
    $line .= $COLORS->{reset};

    print $line . "\r\n";
}

sub render_data_rows {
    my $state = shift;
    my $visible_rows = $state->{term_height} - $state->{header_rows} - $state->{footer_rows};

    for my $row_idx (0..$visible_rows-1) {
        my $job_idx = $state->{scroll_offset} + $row_idx;

        if ($job_idx >= scalar @{$state->{jobs}}) {
            # Empty row - fill with normal colors
            print $COLORS->{normal} . (' ' x $state->{term_width}) . $COLORS->{reset} . "\r\n";
            next;
        }

        my $job = $state->{jobs}[$job_idx];
        my $is_active_row = ($job_idx == $state->{active_row});
        my $is_selected = exists $state->{selected}{$job->{JOBID}};

        render_job_row($state, $job, $is_active_row, $is_selected);
    }
}

sub render_job_row {
    my ($state, $job, $is_active_row, $is_selected) = @_;
    my $line = '';

    my $col_pos = 0;
    my $vis_idx = 0;
    for my $i (0..$#{$state->{columns}}) {
        my $col = $state->{columns}[$i];
        next unless $col->{visible};

        # Column separator before every column except the first
        if ($vis_idx > 0) {
            last if $state->{term_width} - $col_pos <= 0;
            $line .= $COLORS->{col_sep} . '|';
            $col_pos += 1;
        }

        my $avail = $state->{term_width} - $col_pos;
        last if $avail <= 0;

        my $width = $col->{width};
        $width = $avail if $width > $avail;
        my $field = get_job_field($job, $col->{name});

        # Determine cell color (handle overlapping states)
        my $color;
        my $is_active_col = ($i == $state->{active_col});
        if ($is_active_row && $is_selected && $is_active_col) {
            $color = $COLORS->{selected_active};
        } elsif ($is_active_row && $is_active_col) {
            $color = $COLORS->{active_cell};
        } elsif ($is_active_row) {
            $color = $COLORS->{active_row};
        } elsif ($is_selected) {
            $color = $COLORS->{selected};
        } elsif ($is_active_col) {
            $color = $COLORS->{active_col};
        } else {
            $color = get_cell_color($col->{name}, $field) // $COLORS->{normal};
        }

        $line .= $color . sprintf("%-*s", $width, substr($field, 0, $width));
        $col_pos += $width;
        $vis_idx++;
    }

    # Fill rest of line
    my $remaining = $state->{term_width} - $col_pos;
    if ($remaining > 0) {
        my $fill_color = $is_active_row ? $COLORS->{active_row} :
                        $is_selected ? $COLORS->{selected} :
                        $COLORS->{normal};
        $line .= $fill_color . (' ' x $remaining);
    }

    print $line . $COLORS->{reset} . "\r\n";
}

sub render_footer {
    my $state = shift;

    # ── Filter input prompt (overrides everything else) ──────────────────
    if ($state->{input_mode}) {
        my $prefix   = $state->{input_mode} eq 'filter_negate' ? '!' : '/';
        my $col_name = $state->{columns}[$state->{active_col}]{name};
        my $prompt   = "  Filter [$col_name] $prefix" . $state->{input_buffer} . '▌';
        print $COLORS->{filter_prompt}
            . sprintf("%-*s", $state->{term_width}, substr($prompt, 0, $state->{term_width}))
            . $COLORS->{reset};
        return;
    }

    # ── Normal footer ─────────────────────────────────────────────────────
    my $line = $COLORS->{status_bar};
    my $text;

    if ($state->{mode} eq 'confirm_delete') {
        my $num_selected = scalar keys %{$state->{selected}};
        $text = sprintf("Delete %d selected jobs? y/N ", $num_selected);
    } elsif ($state->{message}) {
        $text = $state->{message};
    } else {
        $text = "q:quit  Enter:details  /:filter  !:exclude  ↑↓←→:nav"
              . "  Space:select  [:asc  ]:desc  +:wider  -:narrower"
              . "  \@:hide  ^H:unhide  ^D:delete  r:refresh";
    }

    # Append active-filter count as a right-aligned badge when filters exist
    if (@{$state->{filters}}) {
        my $badge  = sprintf(" [%d filter(s) active] ", scalar @{$state->{filters}});
        my $padded = substr($text, 0, $state->{term_width} - length($badge));
        $text      = sprintf("%-*s", $state->{term_width} - length($badge), $padded) . $badge;
    }

    $line .= sprintf("%-*s", $state->{term_width}, substr($text, 0, $state->{term_width}));
    $line .= $COLORS->{reset};
    print $line;
}

# Field mapping
sub get_job_field {
    my ($job, $field_name) = @_;

    my %field_map = (
        'JobID' => 'JOBID',
        'User' => 'USER',
        'Queue' => 'PARTITION',
        'Name' => 'NAME',
        'State' => 'STATE',
        'Time' => 'TIME',
        'TotalTime' => 'TIME_LIMIT',
        'NodeList' => 'NODELIST',
        'CPUS' => 'CPUS',
        'Memory' => 'MIN_MEMORY',
        'Reason' => 'REASON',
    );

    my $key = $field_map{$field_name} // $field_name;
    return $job->{$key} // 'N/A';
}

# Convert memory string (e.g. "2000M", "8G", "1T") to MB for sorting
sub memory_to_mb {
    my $mem = shift;
    return 0 unless defined $mem && $mem ne 'N/A' && $mem ne '';
    return $1 / 1024          if $mem =~ /^([\d.]+)[Kk]/;
    return $1 + 0             if $mem =~ /^([\d.]+)[Mm]/;
    return $1 * 1024          if $mem =~ /^([\d.]+)[Gg]/;
    return $1 * 1024 * 1024   if $mem =~ /^([\d.]+)[Tt]/;
    return $mem + 0;
}

# Convert SLURM time string ("M:SS", "H:MM:SS", "D-HH:MM:SS") to seconds for sorting
sub time_to_seconds {
    my $time = shift;
    return 0 unless defined $time && $time ne 'N/A' && $time ne '' && $time ne '0:00';
    my $total = 0;
    my $t = $time;
    $total += $1 * 86400 if $t =~ s/^(\d+)-//;   # strip days prefix
    my @p = split(/:/, $t);
    if    (@p == 3) { $total += $p[0]*3600 + $p[1]*60 + int($p[2]); }
    elsif (@p == 2) { $total += $p[0]*60   + int($p[1]); }
    elsif (@p == 1) { $total += int($p[0]); }
    return $total;
}

# Return a sortable value for a column (handles Memory, Time, CPUs specially)
sub get_sort_value {
    my ($job, $col_name) = @_;
    my $display = get_job_field($job, $col_name);
    return memory_to_mb($display)    if $col_name eq 'Memory';
    return time_to_seconds($display) if $col_name eq 'Time' || $col_name eq 'TotalTime';
    return $display + 0              if $col_name eq 'CPUS';
    return $display;
}

# Return a semantic cell color for a column value, or undef for the default
sub get_cell_color {
    my ($col_name, $value) = @_;
    $value //= '';

    if ($col_name eq 'State') {
        return $COLORS->{cell_running} if $value =~ /^RUNNING/i;
        return $COLORS->{cell_pending} if $value =~ /^PENDING/i;
        return $COLORS->{cell_error}   if $value ne 'N/A' && $value ne '';
    } elsif ($col_name eq 'Time') {
        return $COLORS->{cell_time_zero}
            if $value eq '' || $value eq '0:00' || $value eq 'N/A';
    } elsif ($col_name eq 'Reason') {
        # Highlight known error/limit reasons in red
        return $COLORS->{cell_error}
            if $value =~ /^(BadConstraints|PartitionTimeLimit|ReqNodeNotAvail
                            |InvalidAccount|InvalidQOS|JobHoldAdmin
                            |.*Limit$)/ix;
    }
    return undef;
}

# ── Job Detail View ────────────────────────────────────────────────────────────

# Run scontrol show job <jobid> and parse into ordered [[key,val], ...] list.
# Splits each output line on whitespace that precedes a KEY= token so that
# values containing spaces (e.g. paths) are kept intact.
sub fetch_job_details {
    my $jobid = shift;
    my @output = `scontrol show job $jobid 2>&1`;
    my @fields;
    for my $line (@output) {
        chomp $line;
        $line =~ s/^\s+//;
        next if $line =~ /^\s*$/;
        # split before "WORD=" boundaries (handles CPUS/Task, AllocNode:Sid …)
        my @tokens = split /\s+(?=\w[\w:\/]*=)/, $line;
        for my $token (@tokens) {
            push @fields, [$1, $2] if $token =~ /^([^=]+)=(.*)/;
        }
    }
    return \@fields;
}

sub show_job_details {
    my $state = shift;
    return if scalar @{$state->{jobs}} == 0;

    my $job   = $state->{jobs}[$state->{active_row}];
    my $jobid = $job->{JOBID};

    # Show a brief loading flash on the normal screen
    $state->{message} = "Loading details for job $jobid …";
    render_screen($state);
    $state->{message} = '';

    my $fields = fetch_job_details($jobid);
    if (!@$fields) {
        $state->{message} = "No details returned for job $jobid";
        return;
    }

    $state->{detail_fields} = $fields;
    $state->{detail_scroll}  = 0;
    $state->{detail_jobid}   = $jobid;
    $state->{mode}           = 'detail';
}

# Semantic colour for a value in the detail view (returns a colour string)
sub get_detail_value_color {
    my ($key, $val) = @_;
    $val //= '';

    if ($key eq 'JobState') {
        return $COLORS->{cell_running} if $val =~ /^RUNNING/i;
        return $COLORS->{cell_pending} if $val =~ /^PENDING/i;
        return $COLORS->{cell_error}
            if $val =~ /^(FAILED|CANCELLED|TIMEOUT|NODE_FAIL|OUT_OF_MEMORY)/i;
    } elsif ($key eq 'Reason') {
        return $COLORS->{cell_error}
            if $val =~ /^(BadConstraints|PartitionTimeLimit|ReqNodeNotAvail
                          |InvalidAccount|InvalidQOS|JobHoldAdmin|.*Limit$)/ix;
        return $COLORS->{cell_pending}
            if $val =~ /^(Priority|Resources|Dependency|QOSMaxJobsPerUser)/i;
    } elsif ($key eq 'ExitCode') {
        # Non-zero exit code (e.g. "1:0" or "0:1") → red
        return $COLORS->{cell_error} if $val =~ /^[1-9]/ || $val =~ /:[1-9]/;
    }
    return $COLORS->{normal};
}

sub render_detail_screen {
    my $state = shift;
    print $ANSI->{hide_cursor};
    print $ANSI->{clear_screen};
    print $ANSI->{move_home};
    render_detail_header($state);
    render_detail_body($state);
    render_detail_footer($state);
    print $ANSI->{show_cursor};
}

sub render_detail_header {
    my $state = shift;
    # Pull JobName from the parsed fields for the title bar
    my $jobname = '';
    for my $f (@{$state->{detail_fields}}) {
        if ($f->[0] eq 'JobName') { $jobname = $f->[1]; last; }
    }
    my $title = "  Job Details  ·  #$state->{detail_jobid}"
              . ($jobname ? "  ·  $jobname" : '');
    print $COLORS->{header}
        . sprintf("%-*s", $state->{term_width}, substr($title, 0, $state->{term_width}))
        . $COLORS->{reset} . "\r\n";
}

sub render_detail_body {
    my $state   = shift;
    my $fields  = $state->{detail_fields};
    my $visible = $state->{term_height} - 2;   # one header + one footer
    my $KEY_W   = 22;
    my $SEP     = ' │ ';
    my $val_w   = $state->{term_width} - $KEY_W - length($SEP);
    $val_w      = 10 if $val_w < 10;

    for my $ri (0 .. $visible - 1) {
        my $fi = $state->{detail_scroll} + $ri;

        if ($fi >= scalar @$fields) {
            # Empty padding row
            print $COLORS->{normal}
                . (' ' x $state->{term_width})
                . $COLORS->{reset} . "\r\n";
            next;
        }

        my ($key, $val) = @{$fields->[$fi]};
        $val //= '';

        my $val_color = get_detail_value_color($key, $val);

        # Truncate long values; they can be scrolled horizontally in a future version
        my $val_str = substr($val, 0, $val_w);

        print $COLORS->{detail_key} . sprintf("%-*s", $KEY_W, substr($key, 0, $KEY_W))
            . $COLORS->{normal}    . $SEP
            . $val_color           . sprintf("%-*s", $val_w, $val_str)
            . $COLORS->{reset}     . "\r\n";
    }
}

sub render_detail_footer {
    my $state  = shift;
    my $total  = scalar @{$state->{detail_fields}};
    my $vis    = $state->{term_height} - 2;
    my $from   = $state->{detail_scroll} + 1;
    my $to     = min($state->{detail_scroll} + $vis, $total);
    my $text   = sprintf(
        "  ESC/q:back  ↑↓/jk:scroll  PgUp/PgDn:page  g/G:top/bot   [%d-%d of %d fields]",
        $from, $to, $total);
    print $COLORS->{status_bar}
        . sprintf("%-*s", $state->{term_width}, substr($text, 0, $state->{term_width}))
        . $COLORS->{reset};
}

sub handle_detail_input {
    my ($state, $key) = @_;
    my $total = scalar @{$state->{detail_fields}};
    my $vis   = $state->{term_height} - 2;

    if ($key eq 'ESC' || $key eq 'q' || $key eq 'Q'
            || $key eq 'CTRL_C' || $key eq 'ENTER') {
        $state->{mode} = 'normal';
    } elsif ($key eq 'UP' || $key eq 'k') {
        $state->{detail_scroll}-- if $state->{detail_scroll} > 0;
    } elsif ($key eq 'DOWN' || $key eq 'j') {
        $state->{detail_scroll}++
            if $state->{detail_scroll} < $total - $vis;
    } elsif ($key eq 'PGUP') {
        $state->{detail_scroll} = max(0, $state->{detail_scroll} - $vis);
    } elsif ($key eq 'PGDN') {
        $state->{detail_scroll} = max(0,
            min($total - $vis, $state->{detail_scroll} + $vis));
    } elsif ($key eq 'HOME' || $key eq 'g') {
        $state->{detail_scroll} = 0;
    } elsif ($key eq 'END' || $key eq 'G') {
        $state->{detail_scroll} = max(0, $total - $vis);
    }
}

# ── Column filters ────────────────────────────────────────────────────────────

# Re-derive $state->{jobs} by applying every accumulated filter to all_jobs,
# then re-apply the active sort (if any) and clean up stale selections.
sub apply_filters {
    my $state = shift;
    return unless @{$state->{all_jobs}};

    my @jobs = @{$state->{all_jobs}};

    for my $f (@{$state->{filters}}) {
        my ($col, $pat, $neg) = @{$f}{qw(col_name pattern negate)};
        my @kept;
        for my $job (@jobs) {
            my $val     = get_job_field($job, $col);
            my $matches = eval { $val =~ /$pat/i } // 0;
            push @kept, $job if ($neg ? !$matches : $matches);
        }
        @jobs = @kept;
    }

    $state->{jobs} = \@jobs;

    # Clamp active_row before sort so sort_column gets a valid job
    if (!@jobs) {
        $state->{active_row}    = 0;
        $state->{scroll_offset} = 0;
    } else {
        my $max = $#jobs;
        $state->{active_row} = $max if $state->{active_row} > $max;

        # Re-apply whichever column is currently sorted
        for my $col (@{$state->{columns}}) {
            if ($col->{sort} ne 'none') {
                sort_column($state, $col->{sort});
                last;
            }
        }
        adjust_scroll($state);
    }

    # Drop selections for jobs that are no longer visible
    my %visible = map { $_->{JOBID} => 1 } @{$state->{jobs}};
    delete $state->{selected}{$_}
        for grep { !$visible{$_} } keys %{$state->{selected}};
}

# Validate the input buffer, push a new filter, and re-render.
sub apply_filter {
    my $state   = shift;
    my $pattern = $state->{input_buffer};

    unless (length $pattern) {
        $state->{message} = "Filter cancelled (empty pattern)";
        return;
    }

    # Validate regex before committing
    my $ok = eval { "" =~ /$pattern/; 1 };
    unless ($ok) {
        (my $err = $@) =~ s/ at .+//s;
        $state->{message} = "Invalid regex: $err";
        return;
    }

    my $col_name = $state->{columns}[$state->{active_col}]{name};
    my $negate   = ($state->{input_mode} eq 'filter_negate');

    push @{$state->{filters}}, {
        col_name => $col_name,
        pattern  => $pattern,
        negate   => $negate,
    };

    apply_filters($state);

    my $prefix = $negate ? '!' : '/';
    $state->{message} = sprintf(
        "Filter %s%s on [%s]  →  %d job(s) shown  (r to reset all filters)",
        $prefix, $pattern, $col_name, scalar @{$state->{jobs}});
}

# Handle keystrokes while the filter prompt is open.
sub handle_filter_input {
    my ($state, $key) = @_;

    if ($key eq 'ESC' || $key eq 'CTRL_C') {
        $state->{input_mode}   = '';
        $state->{input_buffer} = '';
        $state->{message}      = "Filter cancelled";
    } elsif ($key eq 'ENTER') {
        apply_filter($state);
        $state->{input_mode}   = '';
        $state->{input_buffer} = '';
    } elsif ($key eq 'CTRL_H' || $key eq 'DEL') {
        # Backspace
        $state->{input_buffer} = substr($state->{input_buffer}, 0, -1)
            if length($state->{input_buffer}) > 0;
    } elsif (length($key) == 1 && ord($key) >= 32) {
        $state->{input_buffer} .= $key;
    }
}

# Input handling
sub handle_input {
    my $state = shift;
    my $key = read_key();

    # Clear message after any key press (except in confirm mode)
    if ($state->{mode} ne 'confirm_delete') {
        $state->{message} = '';
    }

    if ($state->{mode} eq 'confirm_delete') {
        return handle_delete_confirmation($state, $key);
    }

    if ($state->{mode} eq 'detail') {
        return handle_detail_input($state, $key);
    }

    # Filter input mode (sub-mode of normal)
    if ($state->{input_mode}) {
        return handle_filter_input($state, $key);
    }

    # Navigation
    if ($key eq 'UP' || $key eq 'k') {
        move_cursor_up($state);
    } elsif ($key eq 'DOWN' || $key eq 'j') {
        move_cursor_down($state);
    } elsif ($key eq 'LEFT' || $key eq 'h') {
        move_cursor_left($state);
    } elsif ($key eq 'RIGHT' || $key eq 'l') {
        move_cursor_right($state);
    }
    # Page navigation
    elsif ($key eq 'PGUP') {
        move_page_up($state);
    } elsif ($key eq 'PGDN') {
        move_page_down($state);
    } elsif ($key eq 'HOME' || $key eq 'g') {
        move_to_top($state);
    } elsif ($key eq 'END' || $key eq 'G') {
        move_to_bottom($state);
    }
    # Selection
    elsif ($key eq ' ') {
        toggle_selection($state);
    }
    # Sorting
    elsif ($key eq '[') {
        sort_column($state, 'asc');
    } elsif ($key eq ']') {
        sort_column($state, 'desc');
    }
    # Column width
    elsif ($key eq '+' || $key eq '=') {
        adjust_column_width($state, +2);
    } elsif ($key eq '-' || $key eq '_') {
        adjust_column_width($state, -2);
    }
    # Column visibility
    elsif ($key eq '@') {
        hide_active_column($state);
    } elsif ($key eq 'CTRL_H') {
        unhide_all_columns($state);
    }
    # Filter: / = keep matching rows, ! = keep non-matching rows
    elsif ($key eq '/') {
        $state->{input_mode}   = 'filter_include';
        $state->{input_buffer} = '';
    } elsif ($key eq '!') {
        $state->{input_mode}   = 'filter_negate';
        $state->{input_buffer} = '';
    }
    # Job details
    elsif ($key eq 'ENTER') {
        show_job_details($state);
    }
    # Delete
    elsif ($key eq 'CTRL_D' || $key eq 'd') {
        if (scalar keys %{$state->{selected}} > 0) {
            $state->{mode} = 'confirm_delete';
        } else {
            $state->{message} = "No jobs selected (use Space to select)";
        }
    }
    # Quit
    elsif ($key eq 'q' || $key eq 'Q' || $key eq 'CTRL_C') {
        $state->{running} = 0;
    }
    # Refresh/Redraw
    elsif ($key eq 'r' || $key eq 'R' || $key eq 'CTRL_R') {
        $state->{message} = "Refreshing...";
        render_screen($state);
        load_jobs($state);
    } elsif ($key eq 'CTRL_L') {
        # Just redraw screen (no data reload)
    }
}

# Navigation functions
sub move_cursor_up {
    my $state = shift;
    return if scalar @{$state->{jobs}} == 0;

    if ($state->{active_row} > 0) {
        $state->{active_row}--;
        adjust_scroll($state);
    }
}

sub move_cursor_down {
    my $state = shift;
    return if scalar @{$state->{jobs}} == 0;

    my $max_row = scalar(@{$state->{jobs}}) - 1;
    if ($state->{active_row} < $max_row) {
        $state->{active_row}++;
        adjust_scroll($state);
    }
}

sub move_cursor_left {
    my $state = shift;

    # Find previous visible column
    my $start_col = $state->{active_col};
    my $col = $start_col;

    do {
        $col--;
        if ($col < 0) {
            $col = $#{$state->{columns}};
        }

        if ($state->{columns}[$col]{visible}) {
            $state->{active_col} = $col;
            return;
        }
    } while ($col != $start_col);
}

sub move_cursor_right {
    my $state = shift;

    # Find next visible column
    my $start_col = $state->{active_col};
    my $col = $start_col;

    do {
        $col++;
        if ($col > $#{$state->{columns}}) {
            $col = 0;
        }

        if ($state->{columns}[$col]{visible}) {
            $state->{active_col} = $col;
            return;
        }
    } while ($col != $start_col);
}

sub move_page_up {
    my $state = shift;
    return if scalar @{$state->{jobs}} == 0;

    my $visible_rows = $state->{term_height} - $state->{header_rows} - $state->{footer_rows};
    $state->{active_row} = max(0, $state->{active_row} - $visible_rows);
    adjust_scroll($state);
}

sub move_page_down {
    my $state = shift;
    return if scalar @{$state->{jobs}} == 0;

    my $visible_rows = $state->{term_height} - $state->{header_rows} - $state->{footer_rows};
    my $max_row = scalar(@{$state->{jobs}}) - 1;
    $state->{active_row} = min($max_row, $state->{active_row} + $visible_rows);
    adjust_scroll($state);
}

sub move_to_top {
    my $state = shift;
    return if scalar @{$state->{jobs}} == 0;

    $state->{active_row} = 0;
    adjust_scroll($state);
}

sub move_to_bottom {
    my $state = shift;
    return if scalar @{$state->{jobs}} == 0;

    $state->{active_row} = scalar(@{$state->{jobs}}) - 1;
    adjust_scroll($state);
}

sub adjust_scroll {
    my $state = shift;
    my $visible_rows = $state->{term_height} - $state->{header_rows} - $state->{footer_rows};

    # Scroll down if cursor below visible area
    if ($state->{active_row} >= $state->{scroll_offset} + $visible_rows) {
        $state->{scroll_offset} = $state->{active_row} - $visible_rows + 1;
    }

    # Scroll up if cursor above visible area
    if ($state->{active_row} < $state->{scroll_offset}) {
        $state->{scroll_offset} = $state->{active_row};
    }
}

# Helper functions
sub min { $_[0] < $_[1] ? $_[0] : $_[1] }
sub max { $_[0] > $_[1] ? $_[0] : $_[1] }

# Selection and interaction
sub toggle_selection {
    my $state = shift;
    return if scalar @{$state->{jobs}} == 0;

    my $job = $state->{jobs}[$state->{active_row}];
    return unless $job;

    my $jobid = $job->{JOBID};

    if (exists $state->{selected}{$jobid}) {
        delete $state->{selected}{$jobid};
        $state->{message} = "Deselected job $jobid";
    } else {
        $state->{selected}{$jobid} = 1;
        $state->{message} = "Selected job $jobid";
    }
}

sub sort_column {
    my ($state, $direction) = @_;
    return if scalar @{$state->{jobs}} == 0;

    my $col_idx = $state->{active_col};
    my $col = $state->{columns}[$col_idx];

    # Remember the currently active job ID
    my $active_jobid = $state->{jobs}[$state->{active_row}]{JOBID};

    # Update sort state
    $col->{sort} = $direction;

    # Clear other column sorts
    for my $c (@{$state->{columns}}) {
        $c->{sort} = 'none' unless $c == $col;
    }

    # Sort jobs
    my $field_name = $col->{name};
    my @sorted = sort {
        my $val_a = get_sort_value($a, $field_name);
        my $val_b = get_sort_value($b, $field_name);

        # Numeric sort if both values look numeric (int or float)
        if ($val_a =~ /^[\d.]+$/ && $val_b =~ /^[\d.]+$/) {
            return $direction eq 'asc' ? $val_a <=> $val_b : $val_b <=> $val_a;
        }

        # String sort
        return $direction eq 'asc' ? $val_a cmp $val_b : $val_b cmp $val_a;
    } @{$state->{jobs}};

    $state->{jobs} = \@sorted;

    # Find the active job in new position
    for my $i (0..$#{$state->{jobs}}) {
        if ($state->{jobs}[$i]{JOBID} eq $active_jobid) {
            $state->{active_row} = $i;
            adjust_scroll($state);
            last;
        }
    }

    $state->{message} = "Sorted by " . $col->{name} . " (" . $direction . ")";
}

sub adjust_column_width {
    my ($state, $delta) = @_;
    my $col = $state->{columns}[$state->{active_col}];
    my $old_width = $col->{width};
    $col->{width} = max(3, $col->{width} + $delta);

    if ($old_width != $col->{width}) {
        $state->{message} = $col->{name} . " width: " . $col->{width};
    }
}

sub hide_active_column {
    my $state = shift;
    my $col = $state->{columns}[$state->{active_col}];

    # Count visible columns
    my $visible_count = 0;
    for my $c (@{$state->{columns}}) {
        $visible_count++ if $c->{visible};
    }

    # Don't hide if it's the last visible column
    if ($visible_count <= 1) {
        $state->{message} = "Cannot hide last visible column";
        return;
    }

    $col->{visible} = 0;
    $state->{message} = "Hidden column: " . $col->{name};

    # Move to next visible column
    move_cursor_right($state);
}

sub unhide_all_columns {
    my $state = shift;
    for my $col (@{$state->{columns}}) {
        $col->{visible} = 1;
    }
    $state->{message} = "All columns visible";
}

sub handle_delete_confirmation {
    my ($state, $key) = @_;

    if ($key eq 'y' || $key eq 'Y') {
        delete_selected_jobs($state);
        $state->{mode} = 'normal';
    } elsif ($key eq 'n' || $key eq 'N' || $key eq 'CTRL_C' || $key eq 'ESC') {
        $state->{mode} = 'normal';
        $state->{message} = "Delete cancelled";
    }
}

sub delete_selected_jobs {
    my $state = shift;
    my @job_ids = keys %{$state->{selected}};

    if (@job_ids) {
        my $cmd = "scancel " . join(" ", @job_ids);
        my $result = system($cmd);

        if ($result == 0) {
            $state->{message} = sprintf("Deleted %d jobs", scalar @job_ids);
            $state->{selected} = {};
            # Reload jobs after deletion
            sleep 1;  # Give SLURM time to update
            load_jobs($state);
        } else {
            $state->{message} = "Error: scancel failed";
        }
    }
}

sub usage {
    binmode(STDOUT, ':utf8');
    my $user = $ENV{USER} // 'unknown';
    print <<"END";
Usage: viewjobs [options]

Interactive TUI for viewing and managing SLURM jobs.

Options:
  -u, --user <username>   Show only jobs from this user [default: $user]
                          Use 'all' to show all users
  -n, --name <pattern>    Show only jobs whose name matches this regex [default: .+]
  -s, --status <pattern>  Show only jobs whose state matches this regex [default: .+]
  -r, --running           Show only running jobs  (shorthand for -s '^RUNNING')
  -p, --pending           Show only pending jobs  (shorthand for -s '^PENDING')
  -h, --help              Show this help and exit

Keyboard shortcuts (inside the TUI):
  q / Ctrl+C    Quit
  Enter         Open full job details (via scontrol)
  /             Filter: keep rows where the active column matches a pattern
  !             Filter: exclude rows where the active column matches a pattern
  r             Refresh job list and reset all interactive filters
  ↑↓ / j k     Move cursor up/down
  ←→ / h l     Move cursor left/right (columns)
  PgUp/PgDn     Scroll by page          g / G   Jump to top / bottom
  Space         Toggle job selection
  [ / ]         Sort active column ascending / descending
  + / -         Widen / narrow active column
  \@             Hide active column      Ctrl+H  Unhide all columns
  d / Ctrl+D    Delete selected jobs (confirmation required)
  Ctrl+L        Redraw screen

END
}

# Run main
main();

__END__

=pod

=encoding UTF-8

=head1 NAME

viewjobs - Interactive TUI for viewing and managing SLURM jobs

=head1 VERSION

version 0.16.1

=head1 SYNOPSIS

  viewjobs

=head1 DESCRIPTION

An interactive terminal user interface (TUI) for viewing and managing SLURM jobs,
similar to VisiData or less. Provides keyboard navigation, sorting, column management,
job selection, and deletion capabilities.

=head1 FEATURES

=over 4

=item * Interactive table view with keyboard navigation

=item * Multi-job selection with space bar

=item * Column sorting (ascending/descending)

=item * Dynamic column width adjustment

=item * Column hiding/unhiding

=item * Job deletion with confirmation

=item * Real-time job list refresh

=back

=head1 KEYBOARD SHORTCUTS

=head2 Navigation

=over 4

=item * Arrow keys (↑↓←→) or Vim keys (hjkl) - Move cursor

=item * Page Up/Down - Scroll by page

=item * Home/End or g/G - Jump to top/bottom

=back

=head2 Selection and Actions

=over 4

=item * Space - Toggle job selection

=item * [ - Sort column ascending

=item * ] - Sort column descending

=item * + or = - Increase column width

=item * - - Decrease column width

=item * @ - Hide current column

=item * Ctrl+H - Unhide all columns

=item * d or Ctrl+D - Delete selected jobs (with confirmation)

=item * r or Ctrl+R - Refresh job list

=item * Ctrl+L - Redraw screen

=item * q or Ctrl+C - Quit

=back

=head1 EXAMPLES

=over 4

=item B<Example 1:> Basic usage

  viewjobs

Launch the interactive viewer and navigate with arrow keys.

=item B<Example 2:> Select and delete jobs

  1. Use arrow keys to navigate to a job
  2. Press Space to select it
  3. Select additional jobs if needed
  4. Press 'd' to delete
  5. Confirm with 'y'

=item B<Example 3:> Sort and customize view

  1. Use left/right arrows to select a column
  2. Press '[' to sort ascending or ']' for descending
  3. Press '+' or '-' to adjust column width
  4. Press '@' to hide unwanted columns

=back

=head1 NOTES

=over 4

=item * Requires a terminal with ANSI color support

=item * Terminal resize requires restarting the application

=item * Array jobs show individual job elements

=back

=head1 SEE ALSO

L<lsjobs>, L<squeue>, L<scancel>

=head1 AUTHOR

Andrea Telatin <proch@cpan.org>

=head1 COPYRIGHT AND LICENSE

This software is Copyright (c) 2023-2025 by Andrea Telatin.

This is free software, licensed under:

  The MIT (X11) License

=cut
