use strict;
use warnings;
use utf8;

use Capture::Tiny qw(capture);
use Cwd qw(getcwd);
use Developer::Dashboard::JSON qw(json_decode json_encode);
use Encode qw(decode encode);
use File::Path qw(make_path);
use File::Spec;
use File::Temp qw(tempdir);
use IO::Socket::INET;
use LWP::UserAgent;
use Developer::Dashboard::Runtime::Result;
use Test::More;
use Time::HiRes qw(sleep);

local $ENV{HOME} = tempdir(CLEANUP => 1);
local $ENV{PERL5LIB} = join ':', grep { defined && $_ ne '' } '/home/mv/perl5/lib/perl5', ( $ENV{PERL5LIB} || () );
local $ENV{DEVELOPER_DASHBOARD_BOOKMARKS};
local $ENV{DEVELOPER_DASHBOARD_CONFIGS};
local $ENV{DEVELOPER_DASHBOARD_CHECKERS};

my $perl = $^X;
my $repo = getcwd();
chdir $ENV{HOME} or die "Unable to chdir to $ENV{HOME}: $!";
my $lib = File::Spec->catdir( $repo, 'lib' );
my $dashboard = File::Spec->catfile( $repo, 'bin', 'dashboard' );
my $runtime_cli_root = File::Spec->catdir( $ENV{HOME}, '.developer-dashboard', 'cli' );
my $runtime_jq = File::Spec->catfile( $runtime_cli_root, 'jq' );
my $runtime_yq = File::Spec->catfile( $runtime_cli_root, 'yq' );
my $runtime_tomq = File::Spec->catfile( $runtime_cli_root, 'tomq' );
my $runtime_propq = File::Spec->catfile( $runtime_cli_root, 'propq' );
my $runtime_iniq = File::Spec->catfile( $runtime_cli_root, 'iniq' );
my $runtime_csvq = File::Spec->catfile( $runtime_cli_root, 'csvq' );
my $runtime_xmlq = File::Spec->catfile( $runtime_cli_root, 'xmlq' );
my $runtime_of = File::Spec->catfile( $runtime_cli_root, 'of' );
my $runtime_open_file = File::Spec->catfile( $runtime_cli_root, 'open-file' );
my $runtime_ticket = File::Spec->catfile( $runtime_cli_root, 'ticket' );

my $init = _run("$perl -I'$lib' '$dashboard' init");
like($init, qr/runtime_root/, 'dashboard init works');
for my $helper ( $runtime_jq, $runtime_yq, $runtime_tomq, $runtime_propq, $runtime_iniq, $runtime_csvq, $runtime_xmlq, $runtime_of, $runtime_open_file, $runtime_ticket ) {
    ok( -f $helper, "dashboard init seeds private helper $helper" );
    ok( -x $helper, "dashboard init marks private helper $helper executable" );
}
my $global_config_file = File::Spec->catfile( $ENV{HOME}, '.developer-dashboard', 'config', 'config.json' );
make_path( File::Spec->catdir( $ENV{HOME}, '.developer-dashboard', 'config' ) );
open my $seeded_config_fh, '>:raw', $global_config_file or die "Unable to write $global_config_file: $!";
my $seeded_config_json = json_encode(
    {
        collectors => [
            {
                name     => 'example.collector',
                command  => "printf 'example collector output\\n'",
                cwd      => 'home',
                interval => 60,
            },
            {
                name      => 'vpn',
                code      => 'return 0;',
                cwd       => 'home',
                indicator => {
                    icon => '🔑',
                },
            },
            {
                name      => 'docker.collector',
                command   => 'docker ps',
                cwd       => 'home',
                indicator => {
                    icon => '🐳',
                },
            },
        ],
    }
);
$seeded_config_json = encode( 'UTF-8', $seeded_config_json ) if utf8::is_utf8($seeded_config_json);
print {$seeded_config_fh} $seeded_config_json;
close $seeded_config_fh;

my $pages = _run("$perl -I'$lib' '$dashboard' page list");
like($pages, qr/welcome/, 'welcome page listed');
like($pages, qr/api-dashboard/, 'dashboard init seeds the API dashboard bookmark');
like($pages, qr/db-dashboard/, 'dashboard init seeds the DB dashboard bookmark');
my $api_page_source = _run("$perl -I'$lib' '$dashboard' page source api-dashboard");
like($api_page_source, qr/^TITLE:\s+API Dashboard/m, 'api-dashboard source is available as a saved bookmark');
unlike($api_page_source, qr/companies house|username=|password=|dsn=/i, 'api-dashboard bookmark source stays free of legacy sensitive details');
my $db_page_source = _run("$perl -I'$lib' '$dashboard' page source db-dashboard");
like($db_page_source, qr/^TITLE:\s+DB Dashboard/m, 'db-dashboard source is available as a saved bookmark');
unlike($db_page_source, qr/companies house|username=|password=|dsn=/i, 'db-dashboard bookmark source stays free of legacy sensitive details');

my $page_source = _run("$perl -I'$lib' '$dashboard' page source welcome");
like($page_source, qr/^BOOKMARK:\s+welcome/m, 'page source prefers saved page ids over token decoding');

my $collector = _run("$perl -I'$lib' '$dashboard' collector run example.collector");
like($collector, qr/example collector output/, 'collector run works');

my $auth_add = _run("$perl -I'$lib' '$dashboard' auth add-user helper helper-pass-123");
like($auth_add, qr/"username"\s*:\s*"helper"/, 'auth add-user works');

my $auth_list = _run("$perl -I'$lib' '$dashboard' auth list-users");
like($auth_list, qr/"username"\s*:\s*"helper"/, 'auth list-users works');

my $legacy_bookmarks_root = File::Spec->catdir( $ENV{HOME}, 'bookmarks' );
make_path($legacy_bookmarks_root);
chmod 0755, $legacy_bookmarks_root or die "Unable to chmod $legacy_bookmarks_root: $!";
my $legacy_bookmark_file = File::Spec->catfile( $legacy_bookmarks_root, 'legacy.txt' );
open my $legacy_bookmark_fh, '>', $legacy_bookmark_file or die "Unable to write $legacy_bookmark_file: $!";
print {$legacy_bookmark_fh} "legacy\n";
close $legacy_bookmark_fh;
chmod 0644, $legacy_bookmark_file or die "Unable to chmod $legacy_bookmark_file: $!";

my $doctor_hook_root = File::Spec->catdir( $ENV{HOME}, '.developer-dashboard', 'cli', 'doctor.d' );
make_path($doctor_hook_root);
my $doctor_hook = File::Spec->catfile( $doctor_hook_root, '00-extra.pl' );
open my $doctor_hook_fh, '>', $doctor_hook or die "Unable to write $doctor_hook: $!";
print {$doctor_hook_fh} <<'PL';
#!/usr/bin/env perl
print "doctor-hook-ok\n";
PL
close $doctor_hook_fh;
chmod 0700, $doctor_hook or die "Unable to chmod $doctor_hook: $!";

my $doctor_report = json_decode( _run("$perl -I'$lib' '$dashboard' doctor") );
ok( !$doctor_report->{ok}, 'dashboard doctor reports insecure legacy permissions before repair' );
ok(
    grep(
        { $_->{path} eq $legacy_bookmarks_root && $_->{expected_mode} eq '0700' }
          @{ $doctor_report->{issues} || [] }
    ),
    'dashboard doctor reports insecure legacy bookmark directories',
);
ok(
    grep(
        { $_->{path} eq $legacy_bookmark_file && $_->{expected_mode} eq '0600' }
          @{ $doctor_report->{issues} || [] }
    ),
    'dashboard doctor reports insecure legacy bookmark files',
);
is( $doctor_report->{hooks}{'00-extra.pl'}{stdout}, "doctor-hook-ok\n", 'dashboard doctor includes hook stdout in RESULT-backed output' );

my $doctor_fixed = json_decode( _run("$perl -I'$lib' '$dashboard' doctor --fix") );
ok( !$doctor_fixed->{ok}, 'dashboard doctor --fix still reports the findings it repaired in that run' );
is( sprintf( '%04o', ( stat($legacy_bookmarks_root) )[2] & 07777 ), '0700', 'dashboard doctor --fix tightens legacy bookmark directory permissions' );
is( sprintf( '%04o', ( stat($legacy_bookmark_file) )[2] & 07777 ), '0600', 'dashboard doctor --fix tightens legacy bookmark file permissions' );
my $doctor_clean = json_decode( _run("$perl -I'$lib' '$dashboard' doctor") );
ok( $doctor_clean->{ok}, 'dashboard doctor reports success after repair' );

my $indicator_refresh = _run("$perl -I'$lib' '$dashboard' indicator refresh-core");
like($indicator_refresh, qr/docker|project|git/, 'indicator refresh-core works');

my $fake_tmux_dir = File::Spec->catdir( $ENV{HOME}, 'fake-bin' );
make_path($fake_tmux_dir);
my $fake_tmux = File::Spec->catfile( $fake_tmux_dir, 'tmux' );
open my $fake_tmux_fh, '>', $fake_tmux or die "Unable to write $fake_tmux: $!";
print {$fake_tmux_fh} <<'SH';
#!/bin/sh
if [ "$1" = "show-environment" ] && [ "$2" = "TICKET_REF" ]; then
  printf 'TICKET_REF=DD-123\n'
  exit 0
fi
exit 1
SH
close $fake_tmux_fh;
chmod 0755, $fake_tmux or die "Unable to chmod $fake_tmux: $!";
local $ENV{PATH} = join ':', $fake_tmux_dir, ( $ENV{PATH} || () );
local $ENV{TICKET_REF};

my $ps1 = _run("$perl -I'$lib' '$dashboard' ps1 --jobs 1");
like($ps1, qr/\(1 jobs\)|developer-dashboard:master| D /, 'ps1 command works');
like($ps1, qr/🚨🔑/, 'ps1 seeds configured collector indicators before their first run');
like($ps1, qr/🚨🐳/, 'ps1 shows all configured collector indicators, not just previously-run collectors');
like($ps1, qr/🎫:DD-123/, 'ps1 loads the ticket from the tmux session environment when TICKET_REF is not already exported');
my $ps1_extended = _run("$perl -I'$lib' '$dashboard' ps1 --jobs 1 --mode extended --color");
like($ps1_extended, qr/\e\[|\(1 jobs\)/, 'ps1 supports extended/color modes');

my $collector_inspect = _run("$perl -I'$lib' '$dashboard' collector inspect example.collector");
like($collector_inspect, qr/"job"|"status"/, 'collector inspect works');

my ( $usage_stdout, $usage_stderr, $usage_exit ) = capture {
    system $perl, '-I' . $lib, $dashboard;
    return $? >> 8;
};
is( $usage_exit, 1, 'dashboard with no arguments exits with usage status' );
like( $usage_stdout . $usage_stderr, qr/SYNOPSIS|dashboard init/, 'dashboard with no arguments renders POD-backed usage' );

my $help = _run("$perl -I'$lib' '$dashboard' help");
like($help, qr/Description:/, 'dashboard help renders the fuller POD help');
like($help, qr/dashboard serve \[logs \[-f\] \[-n N\]\|workers <N>\]/, 'dashboard help documents serve logs tail/follow flags and serve workers commands');
like($help, qr/dashboard ticket \[ticket-ref\]/, 'dashboard help documents the built-in ticket subcommand');

my $serve_workers_port = _find_free_port();
my $serve_workers = _run("$perl -I'$lib' '$dashboard' serve workers 3 --port $serve_workers_port");
like($serve_workers, qr/"workers"\s*:\s*3/, 'dashboard serve workers persists the default worker count');
my ($serve_workers_pid) = $serve_workers =~ /"pid"\s*:\s*"?(\d+)"?/;
ok( !defined $serve_workers_pid || $serve_workers_pid =~ /^\d+$/, 'dashboard serve workers returns a numeric pid when it starts a stopped web service' );
open my $workers_config_fh, '<', $global_config_file or die "Unable to read $global_config_file: $!";
my $workers_config = do { local $/; <$workers_config_fh> };
close $workers_config_fh;
like( $workers_config, qr/"web"\s*:\s*\{\s*"workers"\s*:\s*3/s, 'dashboard serve workers stores the default worker count in config' );
if ( defined $serve_workers_pid ) {
    my $serve_workers_stop = _run("$perl -I'$lib' '$dashboard' stop");
    like( $serve_workers_stop, qr/"web_pid"\s*:\s*\d+/, 'dashboard stop stops the service started by serve workers' );
}
else {
    pass('dashboard serve workers reused an already-running managed web service instead of starting a new pid');
}
my $live_status_port = _find_free_port();
my $live_status_pid = fork();
die 'Unable to fork live dashboard status probe' if !defined $live_status_pid;
if ( !$live_status_pid ) {
    exec $perl, '-I' . $lib, $dashboard, 'serve', '--foreground', '--host', '127.0.0.1', '--port', $live_status_port;
    die "Unable to exec live dashboard serve: $!";
}
my $status_ua = LWP::UserAgent->new( timeout => 5 );
my $status_response;
for ( 1 .. 40 ) {
    $status_response = $status_ua->get("http://127.0.0.1:$live_status_port/system/status");
    last if $status_response->is_success;
    sleep 0.25;
}
ok( $status_response && $status_response->is_success, 'live foreground runtime exposes the system status endpoint' );
like( decode( 'UTF-8', $status_response->content ), qr/"alias"\s*:\s*"🔑"/, 'live foreground runtime syncs configured collector indicator icons into system status' );
kill 'TERM', $live_status_pid;
waitpid( $live_status_pid, 0 );
my $dashboard_log_file = File::Spec->catfile( $ENV{HOME}, '.developer-dashboard', 'logs', 'dashboard.log' );
make_path( File::Spec->catdir( $ENV{HOME}, '.developer-dashboard', 'logs' ) );
open my $dashboard_log_fh, '>', $dashboard_log_file or die "Unable to write $dashboard_log_file: $!";
print {$dashboard_log_fh} "starman boot line\nDancer2 boot line\n";
close $dashboard_log_fh;
my $serve_logs = _run("$perl -I'$lib' '$dashboard' serve logs");
like($serve_logs, qr/starman boot line/, 'dashboard serve logs prints the web-service log content');
like($serve_logs, qr/Dancer2 boot line/, 'dashboard serve logs includes Dancer2-side log lines');
my $serve_logs_tail = _run("$perl -I'$lib' '$dashboard' serve logs -n 1");
is($serve_logs_tail, "Dancer2 boot line\n", 'dashboard serve logs -n prints only the requested trailing lines');
{
    require IPC::Open3;
    require Symbol;
    my $stderr_fh = Symbol::gensym();
    my $pid = IPC::Open3::open3( undef, my $stdout_fh, $stderr_fh, $perl, '-I' . $lib, $dashboard, 'serve', 'logs', '-f', '-n', '1' );
    my $first = <$stdout_fh>;
    is( $first, "Dancer2 boot line\n", 'dashboard serve logs -f -n prints the requested trailing lines before following new output' );
    open my $append_fh, '>>', $dashboard_log_file or die "Unable to append $dashboard_log_file: $!";
    print {$append_fh} "followed line\n";
    close $append_fh;
    my $followed = <$stdout_fh>;
    is( $followed, "followed line\n", 'dashboard serve logs -f streams appended log lines' );
    kill 'TERM', $pid;
    waitpid( $pid, 0 );
}

my $bookmarks_root = _run("$perl -I'$lib' '$dashboard' path resolve bookmarks_root");
is( $bookmarks_root, File::Spec->catdir( $ENV{HOME}, '.developer-dashboard', 'dashboards' ) . "\n", 'dashboard path resolve supports bookmarks_root alias' );
my $custom_path_root = File::Spec->catdir( $ENV{HOME}, 'custom-path-root' );
make_path($custom_path_root);
my $path_add = _run("$perl -I'$lib' '$dashboard' path add foobar '$custom_path_root'");
like( $path_add, qr/"name"\s*:\s*"foobar"/, 'dashboard path add stores a custom alias' );
like( $path_add, qr/\Q$custom_path_root\E/, 'dashboard path add reports the stored target path' );
open my $global_config_fh, '<', $global_config_file or die "Unable to read $global_config_file: $!";
my $global_config = do { local $/; <$global_config_fh> };
close $global_config_fh;
like( $global_config, qr/"foobar"\s*:\s*"\$HOME\/custom-path-root"/, 'dashboard path add stores home-relative aliases using $HOME in global config' );
my $path_add_again = _run("$perl -I'$lib' '$dashboard' path add foobar '$custom_path_root'");
like( $path_add_again, qr/"name"\s*:\s*"foobar"/, 'dashboard path add is idempotent when the alias already exists' );
my $foobar_resolved = _run("$perl -I'$lib' '$dashboard' path resolve foobar");
is( $foobar_resolved, $custom_path_root . "\n", 'dashboard path resolve supports user-defined aliases' );
my $path_list = _run("$perl -I'$lib' '$dashboard' path list");
like( $path_list, qr/"foobar"\s*:\s*"\Q$custom_path_root\E"/, 'dashboard path list includes user-defined aliases' );

my $shell_bootstrap = _run("$perl -I'$lib' '$dashboard' shell bash");
like( $shell_bootstrap, qr/path resolve \"\$1\"/, 'dashboard shell bootstrap resolves named path aliases before project search' );
my $shell_bootstrap_file = File::Spec->catfile( $ENV{HOME}, 'dashboard-shell.sh' );
open my $shell_bootstrap_fh, '>', $shell_bootstrap_file or die "Unable to write $shell_bootstrap_file: $!";
print {$shell_bootstrap_fh} $shell_bootstrap;
close $shell_bootstrap_fh;
my $which_dir_bookmarks = _run("bash -lc '. \"$shell_bootstrap_file\"; which_dir bookmarks_root'");
is( $which_dir_bookmarks, $bookmarks_root, 'which_dir resolves bookmarks_root through the shell helper' );
my $cdr_bookmarks = _run("bash -lc '. \"$shell_bootstrap_file\"; cdr bookmarks_root; pwd'");
is( $cdr_bookmarks, $bookmarks_root, 'cdr navigates to bookmarks_root through the shell helper' );
my $cdr_foobar = _run("bash -lc '. \"$shell_bootstrap_file\"; cdr foobar; pwd'");
is( $cdr_foobar, $custom_path_root . "\n", 'cdr navigates to a user-defined alias through the shell helper' );
like( $shell_bootstrap, qr/ps1 --jobs \\j --mode compact/, 'dashboard shell bash bootstrap keeps bash job-count prompt rendering' );

my $zsh_bootstrap = _run("$perl -I'$lib' '$dashboard' shell zsh");
like( $zsh_bootstrap, qr/add-zsh-hook precmd _dd_update_prompt/, 'dashboard shell zsh bootstrap refreshes the prompt through a precmd hook' );
like( $zsh_bootstrap, qr/ps1 --jobs \$\{#jobstates\} --mode compact/, 'dashboard shell zsh bootstrap uses zsh job counts for prompt rendering' );
like( $zsh_bootstrap, qr/path resolve \"\$1\"/, 'dashboard shell zsh bootstrap keeps the path helper functions' );

my $sh_bootstrap = _run("$perl -I'$lib' '$dashboard' shell sh");
like( $sh_bootstrap, qr/path resolve \"\$1\"/, 'dashboard shell sh bootstrap keeps the path helper functions' );
like( $sh_bootstrap, qr/ps1 --mode compact/, 'dashboard shell sh bootstrap renders the prompt through dashboard ps1' );
unlike( $sh_bootstrap, qr/\\j/, 'dashboard shell sh bootstrap does not rely on bash-specific job expansion' );
my $sh_bootstrap_file = File::Spec->catfile( $ENV{HOME}, 'dashboard-shell-posix.sh' );
open my $sh_bootstrap_fh, '>', $sh_bootstrap_file or die "Unable to write $sh_bootstrap_file: $!";
print {$sh_bootstrap_fh} $sh_bootstrap;
close $sh_bootstrap_fh;
my $sh_which_dir_bookmarks = _run("sh -lc '. \"$sh_bootstrap_file\"; which_dir bookmarks_root'");
is( $sh_which_dir_bookmarks, $bookmarks_root, 'which_dir resolves bookmarks_root through the POSIX shell helper' );
my $sh_cdr_bookmarks = _run("sh -lc '. \"$sh_bootstrap_file\"; cdr bookmarks_root; pwd'");
is( $sh_cdr_bookmarks, $bookmarks_root, 'cdr navigates to bookmarks_root through the POSIX shell helper' );

my $ps_bootstrap = _run("$perl -I'$lib' '$dashboard' shell ps");
like( $ps_bootstrap, qr/function prompt \{/, 'dashboard shell ps bootstrap uses the PowerShell prompt function instead of a PS1 variable' );
like( $ps_bootstrap, qr/function cdr \{/, 'dashboard shell ps bootstrap exposes the cdr helper' );
like( $ps_bootstrap, qr/\bps1 --mode compact/, 'dashboard shell ps bootstrap still renders prompt text through dashboard ps1' );
unlike( $ps_bootstrap, qr/\bPS1\b/, 'dashboard shell ps bootstrap does not mention the POSIX PS1 environment variable' );
my $path_del = _run("$perl -I'$lib' '$dashboard' path del foobar");
like( $path_del, qr/"name"\s*:\s*"foobar"/, 'dashboard path del reports the removed alias' );
like( $path_del, qr/"removed"\s*:\s*1/, 'dashboard path del removes existing aliases' );
my $path_del_again = _run("$perl -I'$lib' '$dashboard' path del foobar");
like( $path_del_again, qr/"removed"\s*:\s*0/, 'dashboard path del is idempotent for missing aliases' );

my $docker_green_root = File::Spec->catdir( $ENV{HOME}, '.developer-dashboard', 'config', 'docker', 'green' );
make_path($docker_green_root);
open my $docker_green_fh, '>', File::Spec->catfile( $docker_green_root, 'development.compose.yml' )
  or die "Unable to write docker green development compose file: $!";
print {$docker_green_fh} "services:\n  green:\n    image: alpine\n";
close $docker_green_fh;
my $docker_dry_run = _run("$perl -I'$lib' '$dashboard' docker compose --dry-run up -d --build green");
my $docker_dry_run_data = json_decode($docker_dry_run);
ok( grep( { $_ eq '-d' } @{ $docker_dry_run_data->{command} } ), 'dashboard docker compose leaves short docker passthrough flags such as -d untouched' );
ok( grep( { $_ eq '--build' } @{ $docker_dry_run_data->{command} } ), 'dashboard docker compose leaves docker passthrough flags such as --build untouched' );
ok( grep( { $_ eq 'green' } @{ $docker_dry_run_data->{services} } ), 'dashboard docker compose still infers service names from passthrough args when docker flags are present' );
my $fake_bin = File::Spec->catdir( $ENV{HOME}, 'fake-bin' );
make_path($fake_bin);
my $fake_docker = File::Spec->catfile( $fake_bin, 'docker' );
open my $fake_docker_fh, '>', $fake_docker or die "Unable to write $fake_docker: $!";
print {$fake_docker_fh} <<'SH';
#!/bin/sh
printf 'DOCKER:%s\n' "$*"
SH
close $fake_docker_fh;
chmod 0755, $fake_docker or die "Unable to chmod $fake_docker: $!";
my $docker_exec_output = _run("PATH='$fake_bin':\"\$PATH\" $perl -I'$lib' '$dashboard' docker compose up -d --build green");
like( $docker_exec_output, qr/^DOCKER:compose /m, 'dashboard docker compose execs the real docker command for non-dry-run invocations' );
unlike( $docker_exec_output, qr/\"command\"\s*:/, 'dashboard docker compose no longer prints JSON envelopes for non-dry-run invocations' );

my $open_root = File::Spec->catdir( $ENV{HOME}, 'open-file-fixtures' );
make_path($open_root);
my $open_target = File::Spec->catfile( $open_root, 'alpha-notes.txt' );
open my $open_fh, '>', $open_target or die "Unable to write $open_target: $!";
print {$open_fh} "alpha\n";
close $open_fh;

my $open_print = _run("$perl -I'$lib' '$dashboard' open-file --print '$open_root' alpha");
like($open_print, qr/\Q$open_target\E/, 'dashboard open-file prints matching files');

my $second_open_target = File::Spec->catfile( $open_root, 'alpha-second.txt' );
open my $second_open_fh, '>', $second_open_target or die "Unable to write $second_open_target: $!";
print {$second_open_fh} "alpha second\n";
close $second_open_fh;

my $fake_editor_bin = File::Spec->catdir( $ENV{HOME}, 'fake-editor-bin' );
make_path($fake_editor_bin);
my $fake_editor_log = File::Spec->catfile( $ENV{HOME}, 'fake-editor.log' );
my $fake_editor = File::Spec->catfile( $fake_editor_bin, 'fake-editor' );
open my $fake_editor_fh, '>', $fake_editor or die "Unable to write $fake_editor: $!";
print {$fake_editor_fh} <<"SH";
#!/bin/sh
printf '%s\\n' "\$*" > '$fake_editor_log'
SH
close $fake_editor_fh;
chmod 0755, $fake_editor or die "Unable to chmod $fake_editor: $!";

my $open_select = _run(qq{printf '2\\n' | EDITOR='$fake_editor' $perl -I'$lib' '$dashboard' of '$open_root' alpha});
like($open_select, qr/^\d+: \Q$open_target\E$/m, 'dashboard of lists the first matching open-file path');
like($open_select, qr/^\d+: \Q$second_open_target\E$/m, 'dashboard of lists the second matching open-file path');
like($open_select, qr/> /, 'dashboard of prompts with the legacy numbered chooser marker');
open my $fake_editor_log_fh, '<', $fake_editor_log or die "Unable to read $fake_editor_log: $!";
my $fake_editor_args = do { local $/; <$fake_editor_log_fh> };
close $fake_editor_log_fh;
my ($selected_open_path) = $open_select =~ /^2:\s+(.*)$/m;
is($fake_editor_args, "$selected_open_path\n", 'dashboard of opens the selected match through the configured editor');

my $open_select_all = _run(qq{printf '1,2\\n' | EDITOR='$fake_editor' $perl -I'$lib' '$dashboard' of '$open_root' alpha});
like($open_select_all, qr/> /, 'dashboard of keeps the chooser prompt for comma-separated multi selection');
open $fake_editor_log_fh, '<', $fake_editor_log or die "Unable to read $fake_editor_log after multi select: $!";
$fake_editor_args = do { local $/; <$fake_editor_log_fh> };
close $fake_editor_log_fh;
my @multi_selected_open = split / /, $fake_editor_args;
chomp $multi_selected_open[-1];
is_deeply([ sort @multi_selected_open ], [ sort ( $open_target, $second_open_target ) ], 'dashboard of opens both selected files for comma-separated multi selection');

my $fake_vim = File::Spec->catfile( $fake_editor_bin, 'vim' );
open my $fake_vim_fh, '>', $fake_vim or die "Unable to write $fake_vim: $!";
print {$fake_vim_fh} <<"SH";
#!/bin/sh
printf '%s\\n' "\$*" > '$fake_editor_log'
SH
close $fake_vim_fh;
chmod 0755, $fake_vim or die "Unable to chmod $fake_vim: $!";
my $blank_open_all = _run(qq{printf '\\n' | PATH='$fake_editor_bin':"\$PATH" $perl -I'$lib' '$dashboard' of '$open_root' alpha});
like($blank_open_all, qr/> /, 'dashboard of blank-enter chooser still renders the prompt before opening all matches');
open $fake_editor_log_fh, '<', $fake_editor_log or die "Unable to read $fake_editor_log after blank open-all: $!";
$fake_editor_args = do { local $/; <$fake_editor_log_fh> };
close $fake_editor_log_fh;
like($fake_editor_args, qr/^-p /, 'dashboard of blank-enter chooser opens all matches through vim tab mode');

my $jq_scope_root = File::Spec->catdir( $ENV{HOME}, 'jq-scope-fixtures' );
my $jq_scope_cli_root = File::Spec->catdir( $jq_scope_root, 'cli' );
my $jq_scope_js_root  = File::Spec->catdir( $jq_scope_root, 'public', 'js' );
make_path( $jq_scope_cli_root, $jq_scope_js_root );
my $jq_scope_helper = File::Spec->catfile( $jq_scope_cli_root, 'jq' );
open my $jq_scope_helper_fh, '>', $jq_scope_helper or die "Unable to write $jq_scope_helper: $!";
print {$jq_scope_helper_fh} "#!/bin/sh\n";
close $jq_scope_helper_fh;
chmod 0755, $jq_scope_helper or die "Unable to chmod $jq_scope_helper: $!";
my $jq_scope_script = File::Spec->catfile( $jq_scope_js_root, 'jq.js' );
open my $jq_scope_script_fh, '>', $jq_scope_script or die "Unable to write $jq_scope_script: $!";
print {$jq_scope_script_fh} "window.jq = true;\n";
close $jq_scope_script_fh;
my $jq_scope_jquery = File::Spec->catfile( $jq_scope_js_root, 'jquery.js' );
open my $jq_scope_jquery_fh, '>', $jq_scope_jquery or die "Unable to write $jq_scope_jquery: $!";
print {$jq_scope_jquery_fh} "window.jquery = true;\n";
close $jq_scope_jquery_fh;
my $jq_scope_select = _run(qq{cd '$jq_scope_root' && printf '1,2\\n' | EDITOR='$fake_editor' $perl -I'$lib' '$dashboard' of . jq});
like($jq_scope_select, qr/^1: \Q.\/cli\/jq\E$/m, 'dashboard of . jq lists the exact jq helper first');
like($jq_scope_select, qr/^2: \Q.\/public\/js\/jq.js\E$/m, 'dashboard of . jq lists jq.js before jquery.js');
like($jq_scope_select, qr/^3: \Q.\/public\/js\/jquery.js\E$/m, 'dashboard of . jq still includes jquery.js in the chooser');
open $fake_editor_log_fh, '<', $fake_editor_log or die "Unable to read $fake_editor_log after jq scope selection: $!";
$fake_editor_args = do { local $/; <$fake_editor_log_fh> };
close $fake_editor_log_fh;
is($fake_editor_args, "./cli/jq ./public/js/jq.js\n", 'dashboard of . jq opens the selected jq helper and jq.js before jquery.js');

my $of_print = _run("$perl -I'$lib' '$dashboard' of --print '$open_root' alpha");
like($of_print, qr/\Q$open_target\E/, 'dashboard of is shorthand for open-file');

my $runtime_open_print = _run("$perl -I'$lib' '$runtime_open_file' --print '$open_root' alpha");
like($runtime_open_print, qr/\Q$open_target\E/, 'private runtime open-file helper prints matching files');

my $runtime_of_print = _run("$perl -I'$lib' '$runtime_of' --print '$open_root' alpha");
like($runtime_of_print, qr/\Q$open_target\E/, 'private runtime of helper prints matching files');

ok( !-f File::Spec->catfile( $repo, 'bin', 'of' ), 'standalone of executable is no longer shipped from the repo tree' );
ok( !-f File::Spec->catfile( $repo, 'bin', 'open-file' ), 'standalone open-file executable is no longer shipped from the repo tree' );

my $perl_root = File::Spec->catdir( $open_root, 'lib', 'My' );
make_path($perl_root);
my $perl_target = File::Spec->catfile( $perl_root, 'App.pm' );
open my $perl_fh, '>', $perl_target or die "Unable to write $perl_target: $!";
print {$perl_fh} "package My::App;\n1;\n";
close $perl_fh;
local $ENV{PERL5LIB} = join ':', grep { defined && $_ ne '' } File::Spec->catdir( $open_root, 'lib' ), $ENV{PERL5LIB};
my $perl_module = _run("$perl -I'$lib' '$dashboard' open-file --print My::App");
like($perl_module, qr/\Q$perl_target\E/, 'dashboard open-file resolves Perl module names');

my $runtime_perl_module = _run("$perl -I'$lib' '$runtime_open_file' --print My::App");
like($runtime_perl_module, qr/\Q$perl_target\E/, 'private runtime open-file helper resolves Perl module names');

my $java_root = File::Spec->catdir( $open_root, 'src', 'com', 'example' );
make_path($java_root);
my $java_target = File::Spec->catfile( $java_root, 'App.java' );
open my $java_fh, '>', $java_target or die "Unable to write $java_target: $!";
print {$java_fh} "package com.example;\nclass App {}\n";
close $java_fh;
my $java_class = _run("cd '$open_root' && $perl -I'$repo/lib' '$repo/bin/dashboard' open-file --print com.example.App");
like($java_class, qr/\Q$java_target\E/, 'dashboard open-file resolves Java class names');

my $runtime_java_class = _run("cd '$open_root' && $perl -I'$repo/lib' '$runtime_open_file' --print com.example.App");
like($runtime_java_class, qr/\Q$java_target\E/, 'private runtime open-file helper resolves Java class names');

my $fake_ticket_bin = File::Spec->catdir( $ENV{HOME}, 'fake-ticket-bin' );
make_path($fake_ticket_bin);
my $fake_ticket_log = File::Spec->catfile( $ENV{HOME}, 'fake-ticket-tmux.log' );
my $fake_ticket_tmux = File::Spec->catfile( $fake_ticket_bin, 'tmux' );
open my $fake_ticket_tmux_fh, '>', $fake_ticket_tmux or die "Unable to write $fake_ticket_tmux: $!";
print {$fake_ticket_tmux_fh} <<"SH";
#!/bin/sh
printf '%s\\n' "\$*" >> '$fake_ticket_log'
if [ "\$1" = "has-session" ]; then
  if [ "\$3" = "DD-NEW" ]; then
    exit 1
  fi
  exit 0
fi
exit 0
SH
close $fake_ticket_tmux_fh;
chmod 0755, $fake_ticket_tmux or die "Unable to chmod $fake_ticket_tmux: $!";
my $ticket_output = _run("PATH='$fake_ticket_bin':\"\$PATH\" $perl -I'$lib' '$dashboard' ticket DD-NEW");
is( $ticket_output, '', 'dashboard ticket stays quiet on success while tmux handles the terminal attach' );
open my $fake_ticket_log_fh, '<', $fake_ticket_log or die "Unable to read $fake_ticket_log: $!";
my $fake_ticket_log_text = do { local $/; <$fake_ticket_log_fh> };
close $fake_ticket_log_fh;
like( $fake_ticket_log_text, qr/^has-session -t DD-NEW$/m, 'dashboard ticket checks whether the requested tmux session already exists' );
like( $fake_ticket_log_text, qr/^new-session -d .* -s DD-NEW -n Code1$/m, 'dashboard ticket creates a new tmux session when the ticket session is missing' );
like( $fake_ticket_log_text, qr/^attach-session -t DD-NEW$/m, 'dashboard ticket attaches to the requested tmux session' );
like( $fake_ticket_log_text, qr/TICKET_REF=DD-NEW/, 'dashboard ticket seeds TICKET_REF into new tmux sessions' );

unlink $fake_ticket_log or die "Unable to unlink $fake_ticket_log: $!";
my $runtime_ticket_output = _run("PATH='$fake_ticket_bin':\"\$PATH\" $perl -I'$lib' '$runtime_ticket' DD-EXISTING");
is( $runtime_ticket_output, '', 'private runtime ticket helper stays quiet on success' );
open my $runtime_ticket_log_fh, '<', $fake_ticket_log or die "Unable to read $fake_ticket_log: $!";
my $runtime_ticket_log_text = do { local $/; <$runtime_ticket_log_fh> };
close $runtime_ticket_log_fh;
like( $runtime_ticket_log_text, qr/^has-session -t DD-EXISTING$/m, 'private runtime ticket helper checks the requested session' );
unlike( $runtime_ticket_log_text, qr/^new-session /m, 'private runtime ticket helper skips session creation when tmux reports it already exists' );
like( $runtime_ticket_log_text, qr/^attach-session -t DD-EXISTING$/m, 'private runtime ticket helper attaches to existing sessions' );

my $json_value = _run(qq{printf '{"alpha":{"beta":2}}' | $perl -I'$lib' '$dashboard' jq alpha.beta});
is( $json_value, "2\n", 'jq extracts scalar JSON values' );
my $json_file = File::Spec->catfile( $open_root, 'sample.json' );
open my $json_fh, '>', $json_file or die "Unable to write $json_file: $!";
print {$json_fh} qq|{"alpha":{"beta":2}}|;
close $json_fh;
my $json_root = _run("$perl -I'$lib' '$dashboard' jq '\$d' '$json_file'");
is_deeply( json_decode($json_root), { alpha => { beta => 2 } }, 'jq accepts file then root query with order-independent args' );
my $json_root_stdin = _run("cat '$json_file' | $perl -I'$lib' '$dashboard' jq '\$d'");
is( $json_root_stdin, $json_root, 'jq returns the same whole-document result from stdin and file input' );
my $json_direct = _run(qq{printf '{"alpha":{"beta":2}}' | $perl -I'$lib' '$runtime_jq' alpha.beta});
is( $json_direct, $json_value, 'private runtime jq matches dashboard jq output' );

my $yaml_value = _run(qq{printf 'alpha:\\n  beta: 3\\n' | $perl -I'$lib' '$dashboard' yq alpha.beta});
is( $yaml_value, "3\n", 'yq extracts scalar YAML values' );
my $yaml_file = File::Spec->catfile( $open_root, 'sample.yaml' );
open my $yaml_fh, '>', $yaml_file or die "Unable to write $yaml_file: $!";
print {$yaml_fh} "alpha:\n  beta: 3\n";
close $yaml_fh;
my $yaml_root = _run("$perl -I'$lib' '$dashboard' yq '$yaml_file' '\$d'");
is_deeply( json_decode($yaml_root), { alpha => { beta => '3' } }, 'yq accepts file then root query with order-independent args' );
my $yaml_root_stdin = _run("cat '$yaml_file' | $perl -I'$lib' '$dashboard' yq '\$d'");
is( $yaml_root_stdin, $yaml_root, 'yq returns the same whole-document result from stdin and file input' );
my $yaml_direct = _run(qq{printf 'alpha:\\n  beta: 3\\n' | $perl -I'$lib' '$runtime_yq' alpha.beta});
is( $yaml_direct, $yaml_value, 'private runtime yq matches dashboard yq output' );

my $jq_hook_root = File::Spec->catdir( $ENV{HOME}, '.developer-dashboard', 'cli', 'jq.d' );
make_path($jq_hook_root);
my $jq_hook_one = File::Spec->catfile( $jq_hook_root, '00-first.pl' );
open my $jq_hook_one_fh, '>', $jq_hook_one or die "Unable to write $jq_hook_one: $!";
print {$jq_hook_one_fh} <<'PL';
#!/usr/bin/env perl
print "hook-one\n";
warn "hook-one-err\n";
PL
close $jq_hook_one_fh;
chmod 0755, $jq_hook_one or die "Unable to chmod $jq_hook_one: $!";
my $jq_hook_two = File::Spec->catfile( $jq_hook_root, '01-second.pl' );
my $jq_hook_result = File::Spec->catfile( $ENV{HOME}, 'jq-hook-result.txt' );
open my $jq_hook_two_fh, '>', $jq_hook_two or die "Unable to write $jq_hook_two: $!";
print {$jq_hook_two_fh} <<"PL";
#!/usr/bin/env perl
use strict;
use warnings;
use lib '$repo/lib';
use Developer::Dashboard::Runtime::Result;
open my \$fh, '>', '$jq_hook_result' or die \$!;
print {\$fh} Developer::Dashboard::Runtime::Result::stdout('00-first.pl');
close \$fh;
print "hook-two\n";
warn "hook-two-err\n";
PL
close $jq_hook_two_fh;
chmod 0755, $jq_hook_two or die "Unable to chmod $jq_hook_two: $!";
my $jq_hook_skipped = File::Spec->catfile( $jq_hook_root, 'data.file' );
open my $jq_hook_skipped_fh, '>', $jq_hook_skipped or die "Unable to write $jq_hook_skipped: $!";
print {$jq_hook_skipped_fh} "skip\n";
close $jq_hook_skipped_fh;
chmod 0600, $jq_hook_skipped or die "Unable to chmod $jq_hook_skipped: $!";
my ( $jq_hooked_stdout, $jq_hooked_stderr, $jq_hooked_exit ) = capture {
    system 'sh', '-c', qq{printf '{"alpha":{"beta":2}}' | $perl -I'$lib' '$dashboard' jq alpha.beta};
    return $? >> 8;
};
is( $jq_hooked_exit, 0, 'dashboard jq succeeds when command hook files exist' );
like( $jq_hooked_stdout, qr/^hook-one\n/s, 'dashboard jq streams hook stdout before the main command output' );
like( $jq_hooked_stdout, qr/hook-two\n2\n\z/s, 'dashboard jq keeps the main command output after streamed hook stdout' );
like( $jq_hooked_stderr, qr/hook-one-err\n/, 'dashboard jq streams hook stderr live' );
like( $jq_hooked_stderr, qr/hook-two-err\n/, 'dashboard jq keeps later hook stderr visible' );
open my $jq_hook_result_fh, '<', $jq_hook_result or die "Unable to read $jq_hook_result: $!";
is( do { local $/; <$jq_hook_result_fh> }, "hook-one\n", 'later built-in command hooks can read the accumulated RESULT JSON from earlier hook output' );
close $jq_hook_result_fh;

local $ENV{RESULT} = json_encode(
    {
        '00-first.pl' => {
            stdout    => "hook-one\n",
            stderr    => "hook-one-err\n",
            exit_code => 0,
        },
        '01-second.pl' => {
            stdout    => "hook-two\n",
            stderr    => "hook-two-err\n",
            exit_code => 0,
        },
    }
);
is_deeply( Developer::Dashboard::Runtime::Result::current(), json_decode( $ENV{RESULT} ), 'Runtime::Result decodes RESULT into a hash' );
is_deeply( [ Developer::Dashboard::Runtime::Result::names() ], [ '00-first.pl', '01-second.pl' ], 'Runtime::Result lists stored hook names in sorted order' );
ok( Developer::Dashboard::Runtime::Result::has('00-first.pl'), 'Runtime::Result detects known hook names' );
ok( !Developer::Dashboard::Runtime::Result::has('99-missing.pl'), 'Runtime::Result rejects missing hook names' );
is( Developer::Dashboard::Runtime::Result::stdout('00-first.pl'), "hook-one\n", 'Runtime::Result returns stored hook stdout' );
is( Developer::Dashboard::Runtime::Result::stderr('01-second.pl'), "hook-two-err\n", 'Runtime::Result returns stored hook stderr' );
is( Developer::Dashboard::Runtime::Result::exit_code('01-second.pl'), 0, 'Runtime::Result returns stored hook exit codes' );
is( Developer::Dashboard::Runtime::Result::last_name(), '01-second.pl', 'Runtime::Result returns the last sorted hook name' );
is_deeply( Developer::Dashboard::Runtime::Result::last_entry(), json_decode( $ENV{RESULT} )->{'01-second.pl'}, 'Runtime::Result returns the last sorted hook entry' );
{
    local $ENV{DEVELOPER_DASHBOARD_COMMAND} = 'update';
    local $0 = '/tmp/report-result/';
    is( Developer::Dashboard::Runtime::Result::_command_name(), 'report-result', 'Runtime::Result preserves a trailing-slash script name when basename still resolves it' );
}
{
    local $ENV{DEVELOPER_DASHBOARD_COMMAND} = 'update';
    local $0 = '/';
    is( Developer::Dashboard::Runtime::Result::_command_name(), 'dashboard', 'Runtime::Result falls back to dashboard when only a root-like script path is available' );
}
like(
    decode( 'UTF-8', Developer::Dashboard::Runtime::Result->report() ),
    qr/^[-]+\n.*Run Report\n[-]+\n✅ 00-first\.pl\n✅ 01-second\.pl\n[-]+\n\z/s,
    'Runtime::Result renders a human-readable hook run report',
);
local $ENV{RESULT} = '{';
my $invalid_json_error = do {
    local $@;
    eval { Developer::Dashboard::Runtime::Result::current() };
    $@;
};
like( $invalid_json_error, qr/at character offset|malformed JSON string/i, 'Runtime::Result surfaces invalid RESULT json decoding errors' );
local $ENV{RESULT} = json_encode( [ 1, 2, 3 ] );
my $non_hash_error = do {
    local $@;
    eval { Developer::Dashboard::Runtime::Result::current() };
    $@;
};
like( $non_hash_error, qr/RESULT must decode to a hash/, 'Runtime::Result rejects non-hash RESULT payloads' );
delete $ENV{RESULT};

my $custom_dir_root = File::Spec->catdir( $ENV{HOME}, '.developer-dashboard', 'cli', 'inspect-result' );
make_path($custom_dir_root);
my $custom_hook = File::Spec->catfile( $custom_dir_root, '00-pre.pl' );
open my $custom_hook_fh, '>', $custom_hook or die "Unable to write $custom_hook: $!";
print {$custom_hook_fh} <<'PL';
#!/usr/bin/env perl
print "custom-hook\n";
warn "custom-hook-err\n";
PL
close $custom_hook_fh;
chmod 0755, $custom_hook or die "Unable to chmod $custom_hook: $!";
my $custom_run = File::Spec->catfile( $custom_dir_root, 'run' );
open my $custom_run_fh, '>', $custom_run or die "Unable to write $custom_run: $!";
print {$custom_run_fh} <<'PL';
#!/usr/bin/env perl
use strict;
use warnings;
print $ENV{RESULT} // '';
PL
close $custom_run_fh;
chmod 0755, $custom_run or die "Unable to chmod $custom_run: $!";
my ( $custom_stdout, $custom_stderr, $custom_exit ) = capture {
    system 'sh', '-c', "$perl -I'$lib' '$dashboard' inspect-result";
    return $? >> 8;
};
is( $custom_exit, 0, 'directory-backed custom command succeeds after hook streaming' );
like( $custom_stdout, qr/^custom-hook\n/s, 'directory-backed custom command streams hook stdout before the final RESULT json' );
like( $custom_stderr, qr/custom-hook-err\n/, 'directory-backed custom command streams hook stderr live' );
my ($custom_json) = $custom_stdout =~ /(\{[\s\S]*\})\s*\z/;
ok( defined $custom_json, 'directory-backed custom command leaves trailing RESULT json after streamed hook output' );
my $custom_result_data = json_decode($custom_json);
is( $custom_result_data->{'00-pre.pl'}{stdout}, "custom-hook\n", 'directory-backed custom commands receive RESULT JSON from their hook files' );
like( $custom_result_data->{'00-pre.pl'}{stderr}, qr/custom-hook-err/, 'directory-backed custom command RESULT keeps captured hook stderr' );

my $report_dir_root = File::Spec->catdir( $ENV{HOME}, '.developer-dashboard', 'cli', 'report-result' );
make_path($report_dir_root);
my $report_hook_ok = File::Spec->catfile( $report_dir_root, '00-first.pl' );
open my $report_hook_ok_fh, '>', $report_hook_ok or die "Unable to write $report_hook_ok: $!";
print {$report_hook_ok_fh} <<'PL';
#!/usr/bin/env perl
print "report-hook\n";
PL
close $report_hook_ok_fh;
chmod 0755, $report_hook_ok or die "Unable to chmod $report_hook_ok: $!";
my $report_hook_fail = File::Spec->catfile( $report_dir_root, '01-second.pl' );
open my $report_hook_fail_fh, '>', $report_hook_fail or die "Unable to write $report_hook_fail: $!";
print {$report_hook_fail_fh} <<'PL';
#!/usr/bin/env perl
warn "report-fail\n";
exit 2;
PL
close $report_hook_fail_fh;
chmod 0755, $report_hook_fail or die "Unable to chmod $report_hook_fail: $!";
my $report_run = File::Spec->catfile( $report_dir_root, 'run' );
open my $report_run_fh, '>', $report_run or die "Unable to write $report_run: $!";
print {$report_run_fh} <<'PL';
#!/usr/bin/env perl
use strict;
use warnings;
use Developer::Dashboard::Runtime::Result;
print Developer::Dashboard::Runtime::Result->report();
PL
close $report_run_fh;
chmod 0755, $report_run or die "Unable to chmod $report_run: $!";
my ( $report_stdout, $report_stderr, $report_exit ) = capture {
    system 'sh', '-c', "$perl -I'$lib' '$dashboard' report-result";
    return $? >> 8;
};
$report_stdout = decode( 'UTF-8', $report_stdout );
$report_stderr = decode( 'UTF-8', $report_stderr );
is( $report_exit, 0, 'directory-backed custom commands can print Runtime::Result reports after hook execution' );
like( $report_stdout, qr/^report-hook\n/s, 'Runtime::Result report command still streams hook stdout before the final report' );
like( $report_stdout, qr/report-result Run Report/, 'Runtime::Result report titles the report with the current command name' );
like( $report_stdout, qr/✅ 00-first\.pl/, 'Runtime::Result report marks successful hooks with a success glyph' );
like( $report_stdout, qr/🚨 01-second\.pl/, 'Runtime::Result report marks failing hooks with an error glyph' );
like( $report_stderr, qr/report-fail/, 'Runtime::Result report does not suppress hook stderr' );

my $update_hook_root = File::Spec->catdir( $ENV{HOME}, '.developer-dashboard', 'cli', 'update.d' );
make_path($update_hook_root);
my $update_command = File::Spec->catfile( $ENV{HOME}, '.developer-dashboard', 'cli', 'update' );
open my $update_command_fh, '>', $update_command or die "Unable to write $update_command: $!";
print {$update_command_fh} <<'PL';
#!/usr/bin/env perl
use strict;
use warnings;
print $ENV{RESULT} // '';
PL
close $update_command_fh;
chmod 0755, $update_command or die "Unable to chmod $update_command: $!";
my $update_hook = File::Spec->catfile( $update_hook_root, '01-cpan' );
open my $update_hook_fh, '>', $update_hook or die "Unable to write $update_hook: $!";
print {$update_hook_fh} <<'PL';
#!/usr/bin/env perl
print "Test";
warn "warned\n";
PL
close $update_hook_fh;
chmod 0755, $update_hook or die "Unable to chmod $update_hook: $!";
my $update_skip = File::Spec->catfile( $update_hook_root, 'data.file' );
open my $update_skip_fh, '>', $update_skip or die "Unable to write $update_skip: $!";
print {$update_skip_fh} "skip\n";
close $update_skip_fh;
chmod 0600, $update_skip or die "Unable to chmod $update_skip: $!";
my ( $update_stdout, $update_stderr, $update_exit ) = capture {
    system 'sh', '-c', "$perl -I'$lib' '$dashboard' update";
    return $? >> 8;
};
is( $update_exit, 0, 'dashboard update custom command succeeds' );
like( $update_stdout, qr/^Test/s, 'dashboard update custom command streams hook stdout before returning RESULT json' );
like( $update_stderr, qr/warned/, 'dashboard update streams hook stderr live' );
my ($update_json) = $update_stdout =~ /(\{[\s\S]*\})\s*\z/;
ok( defined $update_json, 'dashboard update custom command leaves trailing RESULT json after streamed hook output' );
my $update_result_data = json_decode($update_json);
is( $update_result_data->{'01-cpan'}{stdout}, 'Test', 'dashboard update custom command receives stdout from executable update hook files' );
like( $update_result_data->{'01-cpan'}{stderr}, qr/warned/, 'dashboard update custom command receives stderr from executable update hook files' );
ok( !exists $update_result_data->{'data.file'}, 'dashboard update custom command skips non-executable files in the update hook folder' );
is( _run("$perl -I'$lib' '$dashboard' version"), "1.55\n", 'dashboard version prints the installed dashboard version' );

my $toml_value = _run(qq{printf '[alpha]\\nbeta = 4\\n' | $perl -I'$lib' '$dashboard' tomq alpha.beta});
is( $toml_value, "4\n", 'tomq extracts scalar TOML values' );
my $toml_file = File::Spec->catfile( $open_root, 'sample.toml' );
open my $toml_fh, '>', $toml_file or die "Unable to write $toml_file: $!";
print {$toml_fh} "[alpha]\nbeta = 4\n";
close $toml_fh;
my $toml_root = _run("$perl -I'$lib' '$dashboard' tomq '\$d' '$toml_file'");
is_deeply( json_decode($toml_root), { alpha => { beta => 4 } }, 'tomq accepts file then root query with order-independent args' );
my $toml_root_stdin = _run("cat '$toml_file' | $perl -I'$lib' '$dashboard' tomq '\$d'");
is( $toml_root_stdin, $toml_root, 'tomq returns the same whole-document result from stdin and file input' );
my $toml_direct = _run(qq{printf '[alpha]\\nbeta = 4\\n' | $perl -I'$lib' '$runtime_tomq' alpha.beta});
is( $toml_direct, $toml_value, 'private runtime tomq matches dashboard tomq output' );

my $props_value = _run(qq{printf 'alpha.beta=5\\nname = demo\\n' | $perl -I'$lib' '$dashboard' propq alpha.beta});
is( $props_value, "5\n", 'propq extracts scalar Java properties values' );
my $props_file = File::Spec->catfile( $open_root, 'sample.properties' );
open my $props_fh, '>', $props_file or die "Unable to write $props_file: $!";
print {$props_fh} "alpha.beta=5\nname = demo\n";
close $props_fh;
my $props_root = _run("$perl -I'$lib' '$dashboard' propq '$props_file' '\$d'");
is_deeply( json_decode($props_root), { 'alpha.beta' => '5', name => 'demo' }, 'propq accepts file then root query with order-independent args' );
my $props_root_stdin = _run("cat '$props_file' | $perl -I'$lib' '$dashboard' propq '\$d'");
is( $props_root_stdin, $props_root, 'propq returns the same whole-document result from stdin and file input' );
my $props_direct = _run(qq{printf 'alpha.beta=5\\nname = demo\\n' | $perl -I'$lib' '$runtime_propq' alpha.beta});
is( $props_direct, $props_value, 'private runtime propq matches dashboard propq output' );

my $ini_value = _run(qq{printf '[alpha]\\nbeta=6\\n' | $perl -I'$lib' '$dashboard' iniq alpha.beta});
is( $ini_value, "6\n", 'iniq extracts scalar INI values' );
my $ini_direct = _run(qq{printf '[alpha]\\nbeta=6\\n' | $perl -I'$lib' '$runtime_iniq' alpha.beta});
is( $ini_direct, $ini_value, 'private runtime iniq matches dashboard iniq output' );

my $csv_value = _run(qq{printf 'alpha,beta\\n7,8\\n' | $perl -I'$lib' '$dashboard' csvq 1.1});
is( $csv_value, "8\n", 'csvq extracts scalar CSV values by row and column index' );
my $csv_direct = _run(qq{printf 'alpha,beta\\n7,8\\n' | $perl -I'$lib' '$runtime_csvq' 1.1});
is( $csv_direct, $csv_value, 'private runtime csvq matches dashboard csvq output' );

my $xml_value = _run(qq{printf '<root><value>demo</value></root>' | $perl -I'$lib' '$dashboard' xmlq _raw});
is( $xml_value, "<root><value>demo</value></root>\n", 'xmlq can return the raw XML payload through the supported root key' );
my $xml_direct = _run(qq{printf '<root><value>demo</value></root>' | $perl -I'$lib' '$runtime_xmlq' _raw});
is( $xml_direct, $xml_value, 'private runtime xmlq matches dashboard xmlq output' );

my $cli_root = File::Spec->catdir( $ENV{HOME}, '.developer-dashboard', 'cli' );
make_path($cli_root);
my $ext = File::Spec->catfile( $cli_root, 'foobar' );
open my $ext_fh, '>', $ext or die "Unable to write $ext: $!";
print {$ext_fh} <<'SH';
#!/bin/sh
input="$(cat)"
printf 'argv:%s|stdin:%s\n' "$*" "$input"
SH
close $ext_fh;
chmod 0755, $ext or die "Unable to chmod $ext: $!";

my ( $ext_stdout, $ext_stderr, $ext_exit ) = capture {
    open my $pipe, '|-', $perl, '-I' . $lib, $dashboard, 'foobar', 'one', 'two'
      or die "Unable to exec dashboard extension: $!";
    print {$pipe} "hello-extension";
    close $pipe or die "dashboard extension failed: $!";
    return $? >> 8;
};
is( $ext_exit, 0, 'user CLI extension exits successfully' );
is( $ext_stderr, '', 'user CLI extension keeps stderr clean' );
like( $ext_stdout, qr/^argv:one two\|stdin:hello-extension$/m, 'user CLI extension receives argv and stdin passthrough' );

my $plain_repo = File::Spec->catdir( $ENV{HOME}, 'projects', 'plain-restart-project' );
make_path( File::Spec->catdir( $plain_repo, '.git' ) );
my ( $plain_restart_stdout, $plain_restart_stderr, $plain_restart_exit ) = capture {
    system 'sh', '-c', "cd '$plain_repo' && $perl -I'$repo/lib' '$repo/bin/dashboard' restart --host 127.0.0.1 --port 17891";
    return $? >> 8;
};
is( $plain_restart_exit, 0, 'dashboard restart succeeds from a repo without a project-local dashboard root' );
unlike( $plain_restart_stderr, qr/\S/, 'dashboard restart keeps stderr clean in a repo without a project-local dashboard root' );
ok( !-d File::Spec->catdir( $plain_repo, '.developer-dashboard' ), 'dashboard restart does not create a project-local .developer-dashboard tree in repos that have not opted in' );
my ( undef, $plain_stop_stderr, $plain_stop_exit ) = capture {
    system $perl, '-I' . $lib, $dashboard, 'stop';
    return $? >> 8;
};
is( $plain_stop_exit, 0, 'dashboard stop succeeds after the plain-repo restart check' );
unlike( $plain_stop_stderr, qr/\S/, 'dashboard stop keeps stderr clean after the plain-repo restart check' );

my $project_root = File::Spec->catdir( $ENV{HOME}, 'projects', 'local-cli-project' );
make_path( File::Spec->catdir( $project_root, '.git' ) );
my $project_cli_root = File::Spec->catdir( $project_root, '.developer-dashboard', 'cli' );
make_path( File::Spec->catdir( $project_cli_root, 'foobar' ) );
open my $project_ext_fh, '>', File::Spec->catfile( $project_cli_root, 'foobar', 'run' )
  or die "Unable to write project run command: $!";
print {$project_ext_fh} <<'SH';
#!/bin/sh
printf 'project-command:%s\n' "$*"
SH
close $project_ext_fh;
chmod 0755, File::Spec->catfile( $project_cli_root, 'foobar', 'run' )
  or die "Unable to chmod project run command: $!";
make_path( File::Spec->catdir( $cli_root, 'jq.d' ) );
open my $home_hook_fh, '>', File::Spec->catfile( $cli_root, 'jq.d', '02-home-only.pl' )
  or die "Unable to write home fallback hook: $!";
print {$home_hook_fh} <<'PL';
#!/usr/bin/env perl
print "home-hook\n";
PL
close $home_hook_fh;
chmod 0755, File::Spec->catfile( $cli_root, 'jq.d', '02-home-only.pl' )
  or die "Unable to chmod home fallback hook: $!";
my $project_tool_hook_root = File::Spec->catdir( $project_cli_root, 'tool.d' );
make_path($project_tool_hook_root);
open my $project_pjq_first_fh, '>', File::Spec->catfile( $project_tool_hook_root, '00-project-first.pl' )
  or die "Unable to write project-first hook: $!";
print {$project_pjq_first_fh} <<'PL';
#!/usr/bin/env perl
print "project-hook\n";
PL
close $project_pjq_first_fh;
chmod 0755, File::Spec->catfile( $project_tool_hook_root, '00-project-first.pl' )
  or die "Unable to chmod project-first hook: $!";
open my $project_pjq_run_fh, '>', File::Spec->catfile( $project_cli_root, 'tool' )
  or die "Unable to write project tool command: $!";
print {$project_pjq_run_fh} <<'PL';
#!/usr/bin/env perl
use strict;
use warnings;
print $ENV{RESULT} // '';
PL
close $project_pjq_run_fh;
chmod 0755, File::Spec->catfile( $project_cli_root, 'tool' )
  or die "Unable to chmod project tool command: $!";

my ( $project_command_stdout, undef, $project_command_exit ) = capture {
    system 'sh', '-c', "cd '$project_root' && $perl -I'$repo/lib' '$repo/bin/dashboard' foobar one two";
    return $? >> 8;
};
is( $project_command_exit, 0, 'project-local custom command exits successfully' );
like( $project_command_stdout, qr/^project-command:one two$/m, 'project-local custom command overrides the home CLI command when a local dashboard root exists' );

my ( $project_hook_stdout, $project_hook_stderr, $project_hook_exit ) = capture {
    system 'sh', '-c', "cd '$project_root' && $perl -I'$repo/lib' '$repo/bin/dashboard' tool";
    return $? >> 8;
};
is( $project_hook_exit, 0, 'project-local hook-backed command exits successfully' );
like( $project_hook_stdout, qr/project-hook/, 'project-local hook directories run before the final command' );
unlike( $project_hook_stdout, qr/home-hook/, 'project-local hook directories override home hook directories instead of merging them' );
like( $project_hook_stdout, qr/00-project-first\.pl/, 'project-local hook results are propagated into RESULT for the final command' );
is( $project_hook_stderr, '', 'project-local hook-backed command keeps stderr clean' );

my $project_local_bookmarks = File::Spec->catdir( $project_root, '.developer-dashboard', 'dashboards' );
make_path($project_local_bookmarks);
my $project_local_paths = _run("cd '$project_root' && $perl -I'$repo/lib' '$repo/bin/dashboard' paths");
like( $project_local_paths, qr/"runtime_root"\s*:\s*"\Q$project_root\/.developer-dashboard\E"/, 'dashboard paths reports the project-local runtime root when present' );
like( $project_local_paths, qr/"dashboards_root"\s*:\s*"\Q$project_local_bookmarks\E"/, 'dashboard paths reports the project-local dashboards root when present' );

done_testing;

sub _run {
    my ($cmd) = @_;
    my ( $stdout, $stderr, $exit_code ) = capture {
        system 'sh', '-c', $cmd;
        return $? >> 8;
    };
    is( $exit_code, 0, "command succeeded: $cmd" );
    return decode( 'UTF-8', $stdout . $stderr );
}

sub _find_free_port {
    my $socket = IO::Socket::INET->new(
        LocalAddr => '127.0.0.1',
        LocalPort => 0,
        Proto     => 'tcp',
        Listen    => 1,
        ReuseAddr => 1,
    ) or die "Unable to reserve a free local TCP port: $!";
    my $port = $socket->sockport();
    close $socket or die "Unable to release reserved local TCP port $port: $!";
    return $port;
}

__END__

=head1 NAME

05-cli-smoke.t - CLI smoke tests for dashboard

=head1 DESCRIPTION

This test verifies the main command-line entrypoints for Developer Dashboard.

=cut
