#!/usr/bin/env perl

use strict;
use warnings;

use Capture::Tiny qw(capture);
use Cwd ();
use File::Path qw(make_path);
use File::Spec;
use File::Temp qw(tempdir);
use JSON::XS qw(decode_json);
use Test::More;

use lib 'lib';

use Developer::Dashboard::CLI::Complete ();
use Developer::Dashboard::CLI::Files ();
use Developer::Dashboard::CLI::Paths ();
use Developer::Dashboard::CLI::Progress ();
use Developer::Dashboard::CLI::Skills ();
use Developer::Dashboard::File ();
use Developer::Dashboard::FileRegistry ();
use Developer::Dashboard::SkillManager ();

{
    package TestCoveragePaths;

    sub new {
        my ( $class, %args ) = @_;
        return bless \%args, $class;
    }

    sub logs_root       { return $_[0]{logs_root}; }
    sub config_root     { return $_[0]{config_root}; }
    sub config_roots    { return ( $_[0]{config_root} ); }
    sub dashboards_root { return $_[0]{dashboards_root}; }
    sub state_root      { return $_[0]{state_root}; }
    sub cwd             { return $_[0]{cwd}; }
    sub current_project_root { return $_[0]{cwd}; }
    sub runtime_roots   { return @{ $_[0]{runtime_roots} || [] }; }
    sub installed_skill_roots { return (); }
    sub secure_file_permissions { return 1; }
}

{
    package TestCLIFileRegistry;

    sub new {
        my ( $class, %args ) = @_;
        return bless {
            paths      => $args{paths},
            named      => {},
            located    => [],
            all_files  => { builtin => '/tmp/builtin.txt' },
        }, $class;
    }

    sub paths { return $_[0]{paths}; }

    sub register_named_files {
        my ( $self, $aliases ) = @_;
        %{$self->{named}} = ( %{$self->{named}}, %{ $aliases || {} } );
        return $self;
    }

    sub unregister_named_file {
        my ( $self, $name ) = @_;
        delete $self->{named}{$name};
        return $self;
    }

    sub named_files {
        my ($self) = @_;
        return { %{ $self->{named} } };
    }

    sub all_files {
        my ($self) = @_;
        return {
            %{ $self->{all_files} },
            %{ $self->{named} },
        };
    }

    sub resolve_file {
        my ( $self, $name ) = @_;
        die "Unknown file name '$name'" if !exists $self->{named}{$name};
        return $self->{named}{$name};
    }

    sub locate_files_under {
        my ( $self, $root, @terms ) = @_;
        return map { File::Spec->catfile( $root, $_ ) } @terms;
    }
}

{
    package TestCLIConfig;

    sub new { return bless {}, shift }

    sub file_aliases {
        return {
            alias_file => '/tmp/alias-file.txt',
            root_dir   => '/tmp/root-dir',
        };
    }

    sub save_global_file_alias {
        my ( $self, $name, $path ) = @_;
        return { name => $name, path => $path };
    }

    sub remove_global_file_alias {
        my ( $self, $name ) = @_;
        return { removed => $name };
    }
}

{
    package TestCLIPathRegistry;

    sub new { return bless {}, shift }

    sub _expand_home {
        my ( $self, $path ) = @_;
        $path =~ s{\A~}{$ENV{HOME} || ''}e if defined $path;
        return $path;
    }
}

{
    package TestCLICollector;

    sub new { return bless {}, shift }

    sub list_collectors {
        return (
            { name => 'collector-two' },
            { name => 'collector-three' },
            { name => 'collector-one' },
        );
    }
}

{
    package TestCLICompleteConfig;

    sub new { return bless {}, shift }

    sub collectors {
        return [
            { name => 'collector-one' },
            { name => 'collector-two' },
            { name => 'collector-one' },
            'junk',
        ];
    }
}

{
    package TestCLISuggest;

    sub new { return bless {}, shift }

    sub top_level_candidates { return qw(skills restart logs); }
    sub skill_commands       { return qw(demo.run); }
}

{
    package TestFileConfig;

    sub new { return bless {}, shift }

    sub file_aliases {
        return { cfg => '/tmp/from-config.txt' };
    }
}

{
    package TestSkillsManager;

    our %USAGE = (
        json_ok => {
            name    => 'demo',
            enabled => 1,
            path    => '/tmp/demo',
            config  => { root => '/tmp/config', file => '/tmp/config/config.json' },
            docker  => { root => '/tmp/docker', services => [ { name => 'web', files => ['compose.yml'] } ] },
            cli     => [ { name => 'hello', has_hooks => 1, hook_count => 2, path => '/tmp/demo/cli/hello' } ],
            pages   => { entries => ['index'], nav_entries => ['nav/foo.tt'] },
            collectors => [ { name => 'beat', qualified_name => 'demo.beat', has_indicator => 1, interval => 30 } ],
        },
        json_err => {
            error => 'usage failed',
            name  => 'broken',
            enabled => 0,
            path => '/tmp/broken',
            config => { root => '/tmp/config', file => '/tmp/config/config.json' },
            docker => { root => '/tmp/docker', services => [] },
            cli => [],
            pages => { entries => [], nav_entries => [] },
            collectors => [],
        },
    );

    sub new {
        my ( $class, %args ) = @_;
        return bless {
            progress   => $args{progress},
            paths      => $args{paths},
            skip_tests => $args{skip_tests},
        }, $class;
    }

    sub registered_skill_sources {
        return ('demo/source');
    }

    sub install_progress_tasks {
        return [ { id => 'fetch', label => 'Fetch' } ];
    }

    sub install_progress_tasks_for_sources {
        my ( $class, @sources ) = @_;
        return [ map { { id => $_, label => $_ } } @sources ];
    }

    sub install {
        my ( $self, $source ) = @_;
        return { repo_name => 'demo', source => $source, status => 'installed' };
    }

    sub install_many {
        my ( $self, @sources ) = @_;
        return {
            success => 1,
            results => [
                map {
                    { repo_name => $_, source => $_, status => 'updated', version_before => '1.00', version_after => '1.01' }
                } @sources
            ],
        };
    }

    sub install_registered_skills {
        return {
            success => 1,
            operations => [
                { repo_name => 'demo', source => 'demo/source', status => 'updated', version_before => '1.00', version_after => '1.01' },
            ],
        };
    }

    sub install_from_ddfiles {
        return {
            success => 1,
            operations => [
                { repo_name => 'dd', source => 'dd/source', status => 'installed', version_before => undef, version_after => '1.00' },
            ],
        };
    }

    sub uninstall { return { success => 1, repo_name => $_[1] } }
    sub enable    { return { success => 1, repo_name => $_[1], enabled => 1 } }
    sub disable   { return { success => 1, repo_name => $_[1], enabled => 0 } }

    sub list {
        return [
            {
                name                  => 'demo',
                enabled               => 1,
                cli_commands_count    => 1,
                pages_count           => 2,
                docker_services_count => 1,
                collectors_count      => 1,
                indicators_count      => 1,
            },
        ];
    }

    sub usage {
        my ( $self, $name ) = @_;
        return $USAGE{$name} if exists $USAGE{$name};
        return $USAGE{json_ok};
    }
}

{
    package TestSkillsProgress;

    sub new {
        my ( $class, @args ) = @_;
        my %args
          = @args == 1 && ref( $args[0] ) eq 'HASH' ? %{ $args[0] }
          : @args % 2 == 0                         ? @args
          : ();
        return bless { %args, finished => 0, events => [] }, $class;
    }

    sub callback {
        my ($self) = @_;
        return sub {
            my ($event) = @_;
            push @{ $self->{events} }, $event;
        };
    }

    sub finish {
        my ($self) = @_;
        $self->{finished} = 1;
        return 1;
    }
}

{
    package TestSkillsDispatcher;

    our $RESULT = { success => 1 };

    sub new { return bless {}, shift }

    sub exec_command { return $RESULT }
}

subtest 'CLI::Progress renders and updates task boards' => sub {
    my $buffer = '';
    open my $stream, '>', \$buffer or die $!;

    my $progress = Developer::Dashboard::CLI::Progress->new(
        title   => 'demo progress',
        tasks   => [
            { id => 'first',  label => 'First task' },
            { id => 'second', label => 'Second task' },
        ],
        stream  => $stream,
        dynamic => 1,
        color   => 1,
    );

    like( $buffer, qr/demo progress/, 'initial render prints the title' );
    like( $buffer, qr/\[ \] First task/, 'initial render shows pending tasks' );
    like( $progress->render_text, qr/\e\[32m\[OK]\e\[0m|\[ \]/, 'render_text returns current board text' );
    is( $progress->_status_prefix('done'), '[OK]', 'done maps to the green OK marker' );
    is( $progress->_status_prefix('running'), '->', 'running maps to the arrow marker' );
    is( $progress->_status_prefix('failed'), '[X]', 'failed maps to the red X marker' );
    is( $progress->_status_prefix('pending'), '[ ]', 'other states stay pending' );
    is( $progress->_colorize( '[OK]', 'done' ), "\e[32m[OK]\e[0m", 'done colorizes green when enabled' );
    is( $progress->_colorize( '->', 'running' ), "\e[33m->\e[0m", 'running colorizes yellow when enabled' );
    is( $progress->_colorize( '[X]', 'failed' ), "\e[31m[X]\e[0m", 'failed colorizes red when enabled' );
    is( $progress->_colorize( '[ ]', 'pending' ), '[ ]', 'pending text stays plain' );

    my $callback = $progress->callback;
    ok( $callback, 'callback returns a coderef' );
    $callback->( { task_id => 'first', status => 'running' } );
    $progress->update( { task_id => 'first', status => 'done', label => 'Completed task' } );
    $progress->update( { task_id => 'second', status => 'failed' } );
    $progress->update(undef);
    $progress->update( { task_id => 'missing', status => 'done' } );
    $progress->finish;

    like( $buffer, qr/\e\[1A\e\[2K/s, 'dynamic re-render clears the previous board' );
    like( $buffer, qr/Completed task/, 'update can replace the task label' );
    like( $buffer, qr/\e\[31m\[X]\e\[0m Second task/, 'failed tasks render with the red marker' );
    like( $buffer, qr/\n\z/, 'finish ensures a trailing newline for dynamic boards' );

    my $plain = '';
    open my $plain_stream, '>', \$plain or die $!;
    my $plain_progress = Developer::Dashboard::CLI::Progress->new(
        tasks  => [ { id => 'only' } ],
        stream => $plain_stream,
    );
    $plain_progress->finish;
    like( $plain, qr/dashboard progress/, 'default title is used when none is supplied' );
};

subtest 'CLI::Complete covers tmux session and collector-name providers' => sub {
    no warnings 'redefine';

    local *Developer::Dashboard::CLI::Ticket::list_sessions = sub { return qw(ticket-123 ticket-456) };
    local *Developer::Dashboard::PathRegistry::new = sub { return bless {}, 'TestCLIPathRegistry' };
    local *Developer::Dashboard::FileRegistry::new = sub { return bless {}, 'TestCLIFileRegistry' };
    local *Developer::Dashboard::Config::new       = sub { return bless {}, 'TestCLICompleteConfig' };
    local *Developer::Dashboard::Collector::new    = sub { return bless {}, 'TestCLICollector' };
    local *Developer::Dashboard::CLI::Suggest::new = sub { return bless {}, 'TestCLISuggest' };

    is_deeply(
        [ Developer::Dashboard::CLI::Complete::_ticket_sessions() ],
        [ qw(ticket-123 ticket-456) ],
        '_ticket_sessions proxies the tmux session list',
    );

    is_deeply(
        [ sort( Developer::Dashboard::CLI::Complete::_collector_names() ) ],
        [ qw(collector-one collector-three collector-two) ],
        '_collector_names merges configured and runtime collector names without duplicates',
    );

    my @top_level = Developer::Dashboard::CLI::Complete::complete(
        words => [ 'dashboard', '' ],
        index => 1,
    );
    ok( scalar grep { $_ eq 'skills' } @top_level, 'complete includes top-level built-in candidates' );

    is_deeply(
        [ Developer::Dashboard::CLI::Complete::complete( words => [ 'dashboard', 'skills', 'i' ], index => 2 ) ],
        ['install'],
        'complete filters static second-level candidates by the active prefix',
    );

    is_deeply(
        [
            sort( Developer::Dashboard::CLI::Complete::complete(
                words            => [ 'dashboard', 'restart', 'collector', 'collector-' ],
                index            => 3,
                collector_names  => sub { return qw(collector-one collector-two); },
            ) )
        ],
        [ qw(collector-one collector-two) ],
        'complete uses the collector-name provider for restart collector completion',
    );

    is_deeply(
        [ Developer::Dashboard::CLI::Complete::_subcommand_candidates('logs') ],
        [ qw(web collector) ],
        '_subcommand_candidates includes runtime-control scopes for logs',
    );
};

subtest 'CLI::Paths covers current-directory delete normalization branches' => sub {
    my $tmp = tempdir( CLEANUP => 1 );
    my $cwd = File::Spec->catdir( $tmp, 'current-dir' );
    make_path($cwd);
    my $previous = File::Spec->catdir( $tmp, 'previous-dir' );
    make_path($previous);
    my $old = Cwd::getcwd();
    chdir $cwd or die "Unable to chdir to $cwd: $!";

    my $config = bless {}, 'TestCLIPathsConfig';
    no warnings 'redefine';
    local *TestCLIPathsConfig::path_aliases = sub {
        return {
            alpha => $previous,
            beta  => $cwd,
        };
    };

    is(
        Developer::Dashboard::CLI::Paths::_normalize_delete_argument(
            paths  => bless( {}, 'TestCLIPathRegistry' ),
            config => $config,
            name   => '.',
        ),
        'beta',
        '_normalize_delete_argument falls back to the first alias that resolves to the current directory',
    );

    local *TestCLIPathsConfig::path_aliases = sub { return { alpha => $previous }; };
    is(
        Developer::Dashboard::CLI::Paths::_normalize_delete_argument(
            paths  => bless( {}, 'TestCLIPathRegistry' ),
            config => $config,
            name   => '.',
        ),
        'current-dir',
        '_normalize_delete_argument falls back to the current directory basename when no alias targets cwd',
    );

    chdir $old or die "Unable to chdir back to $old: $!";
};

subtest 'CLI::Files covers files inventory and locate branches' => sub {
    my $tmp = tempdir( CLEANUP => 1 );
    my $scan_root = File::Spec->catdir( $tmp, 'scan' );
    make_path($scan_root);

    no warnings 'redefine';

    local *Developer::Dashboard::CLI::Files::_build_paths = sub { return bless {}, 'TestCLIPathRegistry' };
    local *Developer::Dashboard::FileRegistry::new = sub {
        return bless {
            named => {},
            all   => { builtin => '/tmp/builtin.txt' },
        }, 'TestCLIFileRegistry';
    };
    local *Developer::Dashboard::Config::new = sub { return bless {}, 'TestCLIConfig' };
    local *TestCLIFileRegistry::register_named_files = sub {
        my ( $self, $aliases ) = @_;
        %{$self->{named}} = ( %{$self->{named}}, %{ $aliases || {} } );
        return $self;
    };
    local *TestCLIFileRegistry::all_files = sub {
        my ($self) = @_;
        return { %{ $self->{all} }, %{ $self->{named} } };
    };
    local *TestCLIFileRegistry::named_files = sub {
        my ($self) = @_;
        return { %{ $self->{named} } };
    };
    local *TestCLIFileRegistry::resolve_file = sub {
        my ( $self, $name ) = @_;
        die "Unknown file name '$name'" if !exists $self->{named}{$name};
        return $self->{named}{$name};
    };
    local *TestCLIFileRegistry::locate_files_under = sub {
        my ( $self, $root, @terms ) = @_;
        return map { File::Spec->catfile( $root, $_ ) } @terms;
    };
    local *TestCLIFileRegistry::unregister_named_file = sub {
        my ( $self, $name ) = @_;
        delete $self->{named}{$name};
        return $self;
    };

    my ( $stdout_files ) = capture {
        Developer::Dashboard::CLI::Files::run_files_command( command => 'files', args => [] );
    };
    my $files_payload = decode_json($stdout_files);
    is( $files_payload->{builtin}, '/tmp/builtin.txt', 'files command prints the merged file inventory' );
    is( $files_payload->{alias_file}, '/tmp/alias-file.txt', 'files command loads configured aliases' );

    my ( $stdout_resolve ) = capture {
        Developer::Dashboard::CLI::Files::run_files_command( command => 'file', args => [ 'resolve', 'alias_file' ] );
    };
    is( $stdout_resolve, "/tmp/alias-file.txt\n", 'file resolve prints the resolved alias path' );

    my ( $stdout_locate_alias ) = capture {
        Developer::Dashboard::CLI::Files::run_files_command( command => 'file', args => [ 'locate', 'root_dir', 'needle.txt' ] );
    };
    is_deeply(
        decode_json($stdout_locate_alias),
        [ '/tmp/root-dir/needle.txt' ],
        'file locate can scope the search to a resolved file alias path',
    );

    my ( $stdout_locate_dir ) = capture {
        Developer::Dashboard::CLI::Files::run_files_command( command => 'file', args => [ 'locate', $scan_root, 'match.txt' ] );
    };
    is_deeply(
        decode_json($stdout_locate_dir),
        [ File::Spec->catfile( $scan_root, 'match.txt' ) ],
        'file locate can scope the search to an explicit directory argument',
    );
};

subtest 'Developer::Dashboard::File and FileRegistry cover direct file helpers' => sub {
    my $tmp = tempdir( CLEANUP => 1 );
    my $logs_root = File::Spec->catdir( $tmp, 'logs' );
    my $config_root = File::Spec->catdir( $tmp, 'config' );
    my $dashboards_root = File::Spec->catdir( $tmp, 'dashboards' );
    my $state_root = File::Spec->catdir( $tmp, 'state' );
    make_path( $logs_root, $config_root, $dashboards_root, $state_root );

    my $paths = TestCoveragePaths->new(
        logs_root       => $logs_root,
        config_root     => $config_root,
        dashboards_root => $dashboards_root,
        state_root      => $state_root,
        cwd             => $tmp,
        runtime_roots   => [$tmp],
    );

    my $registry = Developer::Dashboard::FileRegistry->new( paths => $paths );
    $registry->register_named_files(
        {
            notes => File::Spec->catfile( $tmp, 'notes.txt' ),
            temp  => File::Spec->catfile( $tmp, 'temp.txt' ),
        }
    );

    no warnings 'redefine';
    local *Developer::Dashboard::Config::new = sub { return bless {}, 'TestFileConfig' };

    my $all_aliases = $registry->all_file_aliases;
    is( $all_aliases->{prompt_log}, File::Spec->catfile( $logs_root, 'prompt.log' ), 'all_file_aliases exposes built-in prompt_log' );
    is( $all_aliases->{web_state}, File::Spec->catfile( $state_root, 'web.json' ), 'all_file_aliases exposes built-in web_state' );

    my $all_files = $registry->all_files;
    is( $all_files->{notes}, File::Spec->catfile( $tmp, 'notes.txt' ), 'all_files merges named aliases with built-ins' );
    is( $registry->resolve_file('notes'), File::Spec->catfile( $tmp, 'notes.txt' ), 'resolve_file returns a registered alias path' );
    is( $registry->resolve_file( File::Spec->catfile( $tmp, 'direct.txt' ) ), File::Spec->catfile( $tmp, 'direct.txt' ), 'resolve_file accepts a direct absolute path' );
    is( $registry->resolve_file('cfg'), '/tmp/from-config.txt', 'resolve_file falls back to config-backed aliases' );

    my $scan_root = File::Spec->catdir( $tmp, 'scan' );
    make_path($scan_root);
    my $scan_file = File::Spec->catfile( $scan_root, 'needle-file.txt' );
    open my $scan_fh, '>', $scan_file or die $!;
    print {$scan_fh} "needle\n";
    close $scan_fh or die $!;
    is_deeply( [ $registry->locate_files('needle') ], [ $scan_file ], 'locate_files searches below cwd' );
    is_deeply( [ $registry->locate_files_under( $scan_root, 'needle', 'file' ) ], [ $scan_file ], 'locate_files_under matches every term case-insensitively' );

    $registry->write( 'notes', "first\n" );
    is( $registry->read('notes'), "first\n", 'write and read round-trip file contents' );
    $registry->append( 'notes', "second\n" );
    is( $registry->read('notes'), "first\nsecond\n", 'append extends the existing file' );
    ok( -e $registry->touch('temp'), 'touch creates a missing file' );
    ok( !-e $registry->remove('temp'), 'remove deletes a touched file' );
    $registry->unregister_named_file('notes');
    ok( !exists $registry->named_files->{notes}, 'unregister_named_file removes the alias from the registry' );

    local $Developer::Dashboard::File::FILES = undef;
    local %Developer::Dashboard::File::ALIASES = ();
    local %Developer::Dashboard::File::CONFIG_ALIASES = ();
    local $Developer::Dashboard::File::CONFIG_ALIASES_KEY = '';
    local $ENV{DEVELOPER_DASHBOARD_FILE_ENVONLY} = File::Spec->catfile( $tmp, 'env-only.txt' );
    Developer::Dashboard::File->configure(
        files   => $registry,
        aliases => { direct => File::Spec->catfile( $tmp, 'direct.txt' ) },
    );

    Developer::Dashboard::File->write( direct => "alpha\n" );
    is( Developer::Dashboard::File->read('direct'), "alpha\n", 'File->read uses configured aliases' );
    is( Developer::Dashboard::File->cat('direct'), "alpha\n", 'File->cat is an alias for read' );
    ok( Developer::Dashboard::File->exists('direct'), 'File->exists reports files on disk' );
    ok( !Developer::Dashboard::File->exists('missing'), 'File->exists reports false for unknown files' );
    is( Developer::Dashboard::File->all->{cfg}, '/tmp/from-config.txt', 'File->all returns the registry-backed file inventory hash' );
    ok( Developer::Dashboard::File->touch('direct'), 'File->touch succeeds for configured aliases' );
    is( Developer::Dashboard::File->resolve('envonly'), $ENV{DEVELOPER_DASHBOARD_FILE_ENVONLY}, 'File->resolve falls back to the env override naming convention' );
    ok( Developer::Dashboard::File->rm('direct'), 'File->rm removes an existing aliased file' );
    ok( !-e File::Spec->catfile( $tmp, 'direct.txt' ), 'rm deletes the aliased file path' );

    local $Developer::Dashboard::File::FILES = undef;
    local %Developer::Dashboard::File::ALIASES = ();
    local %Developer::Dashboard::File::CONFIG_ALIASES = ();
    local $Developer::Dashboard::File::CONFIG_ALIASES_KEY = '';
    no warnings 'redefine';
    local *Developer::Dashboard::FileRegistry::new = sub {
        my ( $class, %args ) = @_;
        return bless { paths => $args{paths}, named => {} }, 'TestCLIFileRegistry';
    };
    Developer::Dashboard::File->configure(
        paths   => $paths,
        aliases => { generated => File::Spec->catfile( $tmp, 'generated.txt' ) },
    );
    is(
        Developer::Dashboard::File->resolve('generated'),
        File::Spec->catfile( $tmp, 'generated.txt' ),
        'File->configure can lazily build a registry from paths when no files object is supplied',
    );
};

subtest 'CLI::Skills covers helper branches and table rendering' => sub {
    no warnings 'redefine';

    local *Developer::Dashboard::CLI::Skills::_build_paths = sub { return bless {}, 'TestCLIPathRegistry' };
    local *Developer::Dashboard::SkillManager::new = sub { return TestSkillsManager->new(@_) };
    local *Developer::Dashboard::SkillManager::install_progress_tasks = sub { return TestSkillsManager->install_progress_tasks(@_) };
    local *Developer::Dashboard::SkillManager::install_progress_tasks_for_sources = sub { return TestSkillsManager->install_progress_tasks_for_sources(@_) };
    local *Developer::Dashboard::CLI::Progress::new = sub { return TestSkillsProgress->new(@_) };
    local *Developer::Dashboard::SkillDispatcher::new = sub { return bless {}, 'TestSkillsDispatcher' };
    local *Developer::Dashboard::SkillDispatcher::exec_command = sub { return $TestSkillsDispatcher::RESULT };

    my ( undef, $stderr_usage ) = capture {
        my $exit = Developer::Dashboard::CLI::Skills::run_skills_command(
            command => 'skills',
            args    => [ 'install', '-o', 'yaml' ],
        );
        is( $exit, 2, 'invalid install output returns the usage exit code' );
    };
    like( $stderr_usage, qr/Usage: dashboard skills install/, 'invalid install output prints the usage message' );

    my ( $stdout_uninstall ) = capture {
        my $exit = Developer::Dashboard::CLI::Skills::run_skills_command(
            command => 'skills',
            args    => [ 'uninstall', 'demo' ],
        );
        is( $exit, 0, 'uninstall returns success' );
    };
    is( decode_json($stdout_uninstall)->{repo_name}, 'demo', 'uninstall prints the uninstall payload as JSON' );

    my ( $stdout_enable ) = capture {
        my $exit = Developer::Dashboard::CLI::Skills::run_skills_command(
            command => 'skills',
            args    => [ 'enable', 'demo' ],
        );
        is( $exit, 0, 'enable returns success' );
    };
    ok( decode_json($stdout_enable)->{enabled}, 'enable prints the enabled result' );

    my ( $stdout_disable ) = capture {
        my $exit = Developer::Dashboard::CLI::Skills::run_skills_command(
            command => 'skills',
            args    => [ 'disable', 'demo' ],
        );
        is( $exit, 0, 'disable returns success' );
    };
    ok( !decode_json($stdout_disable)->{enabled}, 'disable prints the disabled result' );

    my ( $stdout_list_json ) = capture {
        my $exit = Developer::Dashboard::CLI::Skills::run_skills_command(
            command => 'skills',
            args    => [ 'list', '-o', 'json' ],
        );
        is( $exit, 0, 'list json returns success' );
    };
    is( decode_json($stdout_list_json)->{skills}[0]{name}, 'demo', 'list json prints the skill payload' );

    my ( $stdout_list_table ) = capture {
        my $exit = Developer::Dashboard::CLI::Skills::run_skills_command(
            command => 'skills',
            args    => [ 'list', '-o', 'table' ],
        );
        is( $exit, 0, 'list table returns success' );
    };
    like( $stdout_list_table, qr/Repo\s+Enabled\s+CLI/, 'list table renders a header row' );

    my $list_usage_ok = eval {
        capture {
            Developer::Dashboard::CLI::Skills::run_skills_command(
                command => 'skills',
                args    => [ 'list', '-o', 'yaml' ],
            );
        };
        1;
    };
    ok( !$list_usage_ok, 'list with invalid output dies with a usage message' );
    like( $@, qr/Usage: dashboard skills list/, 'list with invalid output prints the list usage message' );

    my ( $stdout_usage_json ) = capture {
        my $exit = Developer::Dashboard::CLI::Skills::run_skills_command(
            command => 'skills',
            args    => [ 'usage', 'json_ok', '-o', 'json' ],
        );
        is( $exit, 0, 'usage json returns success' );
    };
    is( decode_json($stdout_usage_json)->{name}, 'demo', 'usage json prints the usage payload' );

    my ( $stdout_usage_table ) = capture {
        my $exit = Developer::Dashboard::CLI::Skills::run_skills_command(
            command => 'skills',
            args    => [ 'usage', 'json_ok', '-o', 'table' ],
        );
        is( $exit, 0, 'usage table returns success' );
    };
    like( $stdout_usage_table, qr/Skill: demo/, 'usage table prints the skill header' );
    like( $stdout_usage_table, qr/CLI Commands/, 'usage table includes the CLI section' );

    my $usage_usage_ok = eval {
        capture {
            Developer::Dashboard::CLI::Skills::run_skills_command(
                command => 'skills',
                args    => [ 'usage', 'json_ok', '-o', 'yaml' ],
            );
        };
        1;
    };
    ok( !$usage_usage_ok, 'usage with invalid output dies with a usage message' );
    like( $@, qr/Usage: dashboard skills usage/, 'usage with invalid output prints the usage message' );

    my $install_table = '';
    my $install_exit = eval {
        $install_table = capture {
            my $exit = Developer::Dashboard::CLI::Skills::run_skills_command(
                command => 'skills',
                args    => [ 'install', 'demo/source' ],
            );
            is( $exit, 0, 'default install table mode returns success' );
        };
        1;
    };
    ok( $install_exit, 'install table mode does not die on a successful install' );
    like( $install_table, qr/Skill\s+Source\s+Before\s+After\s+Status/, 'default install table mode prints the summary table header' );

    my $died = eval {
        no warnings 'redefine';
        local *TestSkillsManager::install = sub { die "kaboom\n" };
        local $ENV{DEVELOPER_DASHBOARD_PROGRESS} = 1;
        capture {
            Developer::Dashboard::CLI::Skills::run_skills_command(
                command => 'skills',
                args    => [ 'install', 'demo/source' ],
            );
        };
        1;
    };
    ok( !$died, 'install propagates manager exceptions' );
    like( $@, qr/kaboom/, 'install reports the manager exception text' );

    my $usage_error = eval {
        capture {
            Developer::Dashboard::CLI::Skills::run_skills_command(
                command => 'skills',
                args    => [ 'usage', 'json_err', '-o', 'table' ],
            );
        };
        1;
    };
    ok( !$usage_error, 'usage table dies when the usage payload contains an error' );
    like( $@, qr/usage failed/, 'usage table propagates the usage error text' );

    local $TestSkillsDispatcher::RESULT = { error => 'dispatch failed' };
    my ( undef, $stderr_exec ) = capture {
        my $exit = Developer::Dashboard::CLI::Skills::run_skills_command(
            command => 'skills',
            args    => [ '_exec', 'demo', 'hello' ],
        );
        is( $exit, 1, '_exec returns failure when the dispatcher reports an error' );
    };
    like( $stderr_exec, qr/dispatch failed/, '_exec prints dispatcher errors to STDERR' );

    local $TestSkillsDispatcher::RESULT = { ok => 1 };
    my $exec_exit = Developer::Dashboard::CLI::Skills::run_skills_command(
        command => 'skills',
        args    => [ '_exec', 'demo', 'hello' ],
    );
    is( $exec_exit, 0, '_exec returns success when the dispatcher succeeds' );

    local $ENV{DEVELOPER_DASHBOARD_PROGRESS} = 1;
    my $one_progress = Developer::Dashboard::CLI::Skills::_skills_install_progress();
    isa_ok( $one_progress, 'TestSkillsProgress', '_skills_install_progress builds a progress object when enabled' );
    my $many_progress = Developer::Dashboard::CLI::Skills::_skills_install_progress_for_sources(qw(one two));
    isa_ok( $many_progress, 'TestSkillsProgress', '_skills_install_progress_for_sources builds a progress object for multiple sources' );
    ok( !Developer::Dashboard::CLI::Skills::_skills_install_progress_for_sources(), 'source progress is skipped when no sources were provided' );

    like(
        Developer::Dashboard::CLI::Skills::_skills_install_summary_table(
            { operations => [ { repo_name => 'demo', source => 'demo', status => 'failed' } ] }
        ),
        qr/No update\./,
        'summary table prints No update when the operations did not change any versions',
    );
    like(
        Developer::Dashboard::CLI::Skills::_skills_install_summary_table(
            { error => 'install failed', operations => [ { repo_name => 'demo', source => 'demo', status => 'failed' } ] }
        ),
        qr/Error: install failed/,
        'summary table prints explicit install errors',
    );
    is_deeply(
        [ Developer::Dashboard::CLI::Skills::_install_result_rows( { results => [ { repo_name => 'demo' } ] } ) ],
        [ { repo_name => 'demo' } ],
        '_install_result_rows normalizes the results payload shape',
    );
    is_deeply(
        [ Developer::Dashboard::CLI::Skills::_install_result_rows( { repo_name => 'single' } ) ],
        [ { repo_name => 'single' } ],
        '_install_result_rows also accepts a single-row payload',
    );
    is_deeply(
        [ Developer::Dashboard::CLI::Skills::_install_result_rows( { message => 'none' } ) ],
        [],
        '_install_result_rows returns an empty list for payloads with no row shape',
    );
    like( Developer::Dashboard::CLI::Skills::_skills_table( TestSkillsManager->list ), qr/enabled/, '_skills_table renders readable enabled text' );
    like( Developer::Dashboard::CLI::Skills::_usage_table( $TestSkillsManager::USAGE{json_ok} ), qr/yes/, '_usage_table renders boolean yes or no values' );

    my $unknown_action_ok = eval {
        capture {
            Developer::Dashboard::CLI::Skills::run_skills_command(
                command => 'skills',
                args    => ['bogus'],
            );
        };
        1;
    };
    ok( !$unknown_action_ok, 'unknown skills actions die with the explicit usage error' );
    like( $@, qr/Unknown skills action: bogus/, 'unknown skills actions print the explicit error text' );
};

subtest 'SkillManager closes the remaining direct error-path coverage branches' => sub {
    my $manager = bless {}, 'Developer::Dashboard::SkillManager';

    my $versionless_root = tempdir( CLEANUP => 1 );
    my $env_file = File::Spec->catfile( $versionless_root, '.env' );
    open my $env_fh, '>', $env_file or die $!;
    print {$env_fh} "# comment only\nNAME=demo\n";
    close $env_fh;
    is(
        $manager->_skill_env_version($versionless_root),
        undef,
        '_skill_env_version returns undef when .env has no VERSION entry',
    );

    is(
        $manager->_dependency_progress_label(
            'install_aptfile',
            tempdir( CLEANUP => 1 ),
            result => { error => "apt exploded\nwith details" },
        ),
        'Install aptfile dependencies (error: apt exploded with details)',
        '_dependency_progress_label still reports errors when the dependency file is absent',
    );

    my @events;
    my $progress_manager = bless {
        progress => sub {
            my ($event) = @_;
            push @events, { %{$event} };
        },
    }, 'Developer::Dashboard::SkillManager';
    my $manifest_root = tempdir( CLEANUP => 1 );
    my $manifest_path = File::Spec->catfile( $manifest_root, 'ddfile' );
    open my $manifest_fh, '>', $manifest_path or die $!;
    print {$manifest_fh} "broken/source\n";
    close $manifest_fh;
    no warnings 'redefine';
    local *Developer::Dashboard::SkillManager::_install_to_skills_root = sub {
        return { error => 'broken source' };
    };
    my @operations;
    my $manifest_failure = $progress_manager->_install_manifest_file(
        $manifest_path,
        manifest_name => 'ddfile',
        skills_root   => File::Spec->catdir( $manifest_root, 'skills' ),
        operations    => \@operations,
        progress      => 1,
    );
    is( $manifest_failure->{error}, 'broken source', '_install_manifest_file returns the failing source result immediately' );
    is_deeply( [ map { $_->{status} } @events ], [ 'running', 'failed' ], '_install_manifest_file emits failed progress before aborting' );
    is_deeply( \@operations, [], '_install_manifest_file does not append operations for failed sources' );

    my $make_root = tempdir( CLEANUP => 1 );
    my $makefile = File::Spec->catfile( $make_root, 'Makefile' );
    open my $make_fh, '>', $makefile or die $!;
    print {$make_fh} "install:\n\t\@true\n";
    close $make_fh;
    my $make_bin = File::Spec->catfile( $make_root, 'make' );
    open my $bin_fh, '>', $make_bin or die $!;
    print {$bin_fh} "#!/bin/sh\n";
    print {$bin_fh} "exit 1\n";
    close $bin_fh;
    chmod 0755, $make_bin or die $!;
    local *Developer::Dashboard::SkillManager::_makefile_targets = sub { return ('install'); };
    local $ENV{PATH} = join ':', $make_root, ( $ENV{PATH} || '' );
    my $make_failure = $manager->_install_skill_makefile($make_root);
    like(
        $make_failure->{error},
        qr/^Failed to run skill Makefile target 'default' for \Q$make_root\E: /,
        '_install_skill_makefile reports the failing default target explicitly',
    );

    my $named_make_root = tempdir( CLEANUP => 1 );
    my $named_makefile = File::Spec->catfile( $named_make_root, 'Makefile' );
    open my $named_make_fh, '>', $named_makefile or die $!;
    print {$named_make_fh} "install:\n\t\@true\n";
    close $named_make_fh;
    my $named_make_bin = File::Spec->catfile( $named_make_root, 'make' );
    open my $named_bin_fh, '>', $named_make_bin or die $!;
    print {$named_bin_fh} "#!/bin/sh\n";
    print {$named_bin_fh} "if [ \"\${1:-default}\" = \"install\" ]; then\n";
    print {$named_bin_fh} "  echo install failed >&2\n";
    print {$named_bin_fh} "  exit 1\n";
    print {$named_bin_fh} "fi\n";
    print {$named_bin_fh} "exit 0\n";
    close $named_bin_fh;
    chmod 0755, $named_make_bin or die $!;
    local $ENV{PATH} = join ':', $named_make_root, ( $ENV{PATH} || '' );
    my $named_make_failure = $manager->_install_skill_makefile($named_make_root);
    like(
        $named_make_failure->{error},
        qr/^Failed to run skill Makefile target 'install' for \Q$named_make_root\E: install failed/m,
        '_install_skill_makefile reports the failing named target explicitly',
    );
};

done_testing();

__END__

=head1 NAME

43-explicit-coverage-qa.t - focused numeric coverage closure for helper modules

=head1 PURPOSE

This test exercises helper modules that are easy to miss in browser and CLI
smoke tests but still count against the explicit 100 percent library coverage
gate.

=head1 WHY IT EXISTS

It exists because the repository now treats the numeric Devel::Cover report as
a standing QA gate after the normal test gate on every change. That means the
helper modules used behind progress boards, file aliases, and lightweight skill
dispatch need direct unit coverage instead of relying on indirect smoke paths.

=head1 WHEN TO USE

Run this file when changing progress rendering, lightweight file and skills
helpers, compatibility file wrappers, or other direct helper branches that do
not naturally get exercised deeply enough by browser or shell smoke tests.

=head1 HOW TO USE

Run it directly with C<prove -lv t/43-explicit-coverage-qa.t> while iterating.
It uses mocked path, config, file-registry, skill-manager, and dispatcher
objects so helper logic can be exercised without booting the whole runtime.

=head1 WHAT USES IT

The explicit numeric Devel::Cover QA gate, the full C<prove -lr t> suite, and
future contributors who need a direct regression target for the helper modules
covered here all depend on this file.

=head1 EXAMPLES

  prove -lv t/43-explicit-coverage-qa.t

Run the focused helper coverage test by itself while iterating on uncovered
branches.

  HARNESS_PERL_SWITCHES=-MDevel::Cover prove -lv t/43-explicit-coverage-qa.t

Collect Devel::Cover data for the helper modules that this file targets.
