#!/usr/bin/env perl

use strict;
use warnings;
use utf8;

use Config ();
use Cwd qw(abs_path cwd);
use FindBin qw($Bin);
use lib "$Bin/../lib";
use File::Spec;
use IO::Handle ();
use IO::Select ();
use IPC::Open3 qw(open3);
use Pod::Usage qw(pod2usage);
use Symbol qw(gensym);

binmode STDOUT, ':encoding(UTF-8)';
binmode STDERR, ':encoding(UTF-8)';

use Developer::Dashboard::Platform qw(
  command_argv_for_path
  is_windows
  is_runnable_file
  resolve_runnable_file
);
use Developer::Dashboard::EnvAudit ();
use Developer::Dashboard::EnvLoader ();
use Developer::Dashboard::CLI::Suggest ();
use Developer::Dashboard::InternalCLI ();
use Developer::Dashboard::Runtime::Result ();

my $dashboard_lib = File::Spec->catdir( $Bin, '..', 'lib' );
my $switchboard_cwd = eval { cwd() };
if ( -d $dashboard_lib ) {
    my $path_sep = $Config::Config{path_sep} || ':';
    my @existing = grep { defined && $_ ne '' } split /\Q$path_sep\E/, ( $ENV{PERL5LIB} || '' );
    if ( !grep { $_ eq $dashboard_lib } @existing ) {
        $ENV{PERL5LIB} = join $path_sep, $dashboard_lib, @existing;
    }
}

my $cmd = shift @ARGV || '';
_load_runtime_env();
_prime_command_result_env( $cmd, @ARGV ) if $cmd ne '';

if ( $cmd eq '' ) {
    pod2usage(
        -exitval  => 1,
        -verbose  => 99,
        -sections => [qw(NAME SYNOPSIS)],
    );
}
elsif ( $cmd eq 'help' || $cmd eq '--help' || $cmd eq '-h' ) {
    pod2usage(
        -exitval => 0,
        -verbose => 99,
    );
}

if ( $cmd eq 'version' ) {
    require Developer::Dashboard;
    print $Developer::Dashboard::VERSION, "\n";
    exit 0;
}

if ( my $helper_path = _builtin_helper_path($cmd) ) {
    _exec_switchboard_command( $helper_path, @ARGV );
}

if ( my $custom_path = _custom_command_path($cmd) ) {
    _exec_switchboard_command( $custom_path, @ARGV );
}

if ( my ( $skill_name, $skill_command ) = _skill_dotted_command_parts($cmd) ) {
    my $helper_path = _builtin_helper_path('skills');
    _exec_switchboard_command( $helper_path, '_exec', $skill_name, $skill_command, @ARGV );
}

if ( $cmd ne '' ) {
    print STDERR Developer::Dashboard::CLI::Suggest->new()->unknown_command_message($cmd);
}

pod2usage(
    -exitval  => 1,
    -verbose  => 99,
    -sections => [qw(NAME SYNOPSIS)],
);

# _prime_command_result_env($cmd, @argv)
# Runs executable per-command hook files from every DD-OOP-LAYERS CLI root and
# exposes their captured output through RESULT JSON for later hooks and the
# final command target.
# Input: top-level command name plus the remaining argv list.
# Output: true value after hook execution completes.
sub _prime_command_result_env {
    my ( $cmd, @argv ) = @_;
    Developer::Dashboard::Runtime::Result::clear_current();
    Developer::Dashboard::Runtime::Result::clear_last_result();
    $ENV{DEVELOPER_DASHBOARD_COMMAND} = $cmd if defined $cmd && $cmd ne '';
    return 1 if !defined $cmd || $cmd eq '';

    my @hook_roots = _command_hook_roots($cmd);
    return 1 if !@hook_roots;
    my $stream = $cmd eq 'doctor' ? 0 : 1;

    my %results;
    for my $hook_root (@hook_roots) {
        opendir my $dh, $hook_root or next;
        for my $entry ( sort grep { $_ ne '.' && $_ ne '..' } readdir $dh ) {
            my $path = File::Spec->catfile( $hook_root, $entry );
            next if !is_runnable_file($path);
            next if $entry eq 'run';

            my $hook_result = _run_command_hook_streaming(
                $path,
                stream => $stream,
                argv   => \@argv,
            );
            my $result_key = exists $results{$entry} ? _command_hook_result_key($path) : $entry;
            $results{$result_key} = {
                stdout => $hook_result->{stdout},
                stderr => $hook_result->{stderr},
            };
            $results{$result_key}{exit_code} = $hook_result->{exit_code} if defined $hook_result->{exit_code};
            Developer::Dashboard::Runtime::Result::set_current( \%results );
            Developer::Dashboard::Runtime::Result::set_last_result(
                {
                    file   => $path,
                    exit   => $hook_result->{exit_code},
                    STDOUT => $hook_result->{stdout},
                    STDERR => $hook_result->{stderr},
                }
            );
            return 1 if Developer::Dashboard::Runtime::Result::stop_requested( $hook_result->{stderr} );
        }
        closedir $dh;
    }

    if ( !%results ) {
        Developer::Dashboard::Runtime::Result::clear_current();
        Developer::Dashboard::Runtime::Result::clear_last_result();
    }
    return 1;
}

# _load_runtime_env()
# Loads the participating DD-OOP-LAYERS plain-directory and runtime-layer env
# files into the current process before command resolution and helper exec.
# Input: none.
# Output: true value.
sub _load_runtime_env {
    require Developer::Dashboard::PathRegistry;
    Developer::Dashboard::EnvAudit->clear();
    my $paths = Developer::Dashboard::PathRegistry->new(
        cwd             => $switchboard_cwd,
        home            => $ENV{HOME},
        workspace_roots => [],
        project_roots   => [],
    );
    Developer::Dashboard::EnvLoader->load_runtime_layers( paths => $paths );
    return 1;
}

# _run_command_hook_streaming($path, %args)
# Executes one hook file, streams its output live, and captures stdout/stderr so
# later hooks and the final command can inspect RESULT JSON.
# Input: executable hook path plus an optional stream flag and argv array ref.
# Output: hash reference containing stdout, stderr, and exit_code.
sub _run_command_hook_streaming {
    my ( $path, %args ) = @_;
    my $stream = exists $args{stream} ? $args{stream} : 1;
    my @argv = @{ $args{argv} || [] };
    open my $stdin, '<', File::Spec->devnull() or die "Unable to open " . File::Spec->devnull() . " for hook stdin: $!";
    my $stderr = gensym();
    my $stdout;
    my @command = command_argv_for_path($path);
    my $pid = open3( $stdin, $stdout, $stderr, @command, @argv );
    close $stdin;

    my $selector  = IO::Select->new( $stdout, $stderr );
    my $stdout_fd = fileno($stdout);
    my $stderr_fd = fileno($stderr);
    my $stdout_text = '';
    my $stderr_text = '';
    local $| = 1;
    STDOUT->autoflush(1);
    STDERR->autoflush(1);

    while ( my @ready = $selector->can_read ) {
        for my $fh (@ready) {
            my $buffer = '';
            my $read = sysread( $fh, $buffer, 8192 );
            if ( !defined $read || $read == 0 ) {
                $selector->remove($fh);
                close $fh;
                next;
            }

            if ( fileno($fh) == $stdout_fd ) {
                print STDOUT $buffer if $stream;
                $stdout_text .= $buffer;
                next;
            }

            if ( fileno($fh) == $stderr_fd ) {
                print STDERR $buffer if $stream;
                $stderr_text .= $buffer;
                next;
            }
        }
    }

    waitpid( $pid, 0 );
    return {
        stdout    => $stdout_text,
        stderr    => $stderr_text,
        exit_code => $? >> 8,
    };
}

# _command_hook_roots($cmd)
# Resolves every per-command hook directory across DD-OOP-LAYERS from home to
# the deepest participating layer.
# Input: top-level command name string.
# Output: ordered list of hook directory paths.
sub _command_hook_roots {
    my ($cmd) = @_;
    return () if !defined $cmd || $cmd eq '';
    my @roots;
    for my $root ( _cli_runtime_layers() ) {
        my $plain_root = File::Spec->catdir( $root, $cmd );
        if ( -d $plain_root ) {
            push @roots, $plain_root;
            next;
        }
        my $d_root = File::Spec->catdir( $root, $cmd . '.d' );
        push @roots, $d_root if -d $d_root;
    }
    return @roots;
}

# _custom_command_path($cmd)
# Resolves the effective layered custom command target from the deepest child
# layer outward to the home runtime.
# Input: top-level command name string.
# Output: file or directory path string, or empty string when absent.
sub _custom_command_path {
    my ($cmd) = @_;
    return '' if !defined $cmd || $cmd eq '';
    for my $root ( _cli_runtime_roots() ) {
        my $path = File::Spec->catfile( $root, $cmd );
        return $path if -d $path;
        my $resolved = resolve_runnable_file($path);
        return $resolved if defined $resolved && $resolved ne '';
    }
    return '';
}

# _builtin_helper_path($cmd)
# Resolves one dashboard-managed built-in helper under the home runtime and
# stages the shipped helper assets there on demand.
# Input: top-level command name string.
# Output: helper file path string or empty string when unsupported.
sub _builtin_helper_path {
    my ($cmd) = @_;
    my $helper = Developer::Dashboard::InternalCLI::canonical_helper_name($cmd);
    return '' if $helper eq '';
    require Developer::Dashboard::PathRegistry;
    my $paths = Developer::Dashboard::PathRegistry->new(
        cwd             => $switchboard_cwd,
        home            => $ENV{HOME},
        workspace_roots => [],
        project_roots   => [],
    );
    Developer::Dashboard::InternalCLI::ensure_helpers( paths => $paths );
    return Developer::Dashboard::InternalCLI::helper_path( paths => $paths, name => $helper );
}

# _skill_dotted_command_parts($cmd)
# Resolves dashboard <skill>.<command> shorthand into one installed skill name and one skill-local command.
# Input: top-level command token such as skill-name.run-test.
# Output: skill name and command name strings, or an empty list when the token is not an installed skill shorthand.
sub _skill_dotted_command_parts {
    my ($cmd) = @_;
    return if !defined $cmd || $cmd eq '' || $cmd !~ /\./;
    my ( $skill_name, $skill_command ) = split /\./, $cmd, 2;
    return if !defined $skill_name || $skill_name eq '';
    return if !defined $skill_command || $skill_command eq '';
    require Developer::Dashboard::PathRegistry;
    require Developer::Dashboard::SkillManager;
    my $paths = Developer::Dashboard::PathRegistry->new(
        cwd             => $switchboard_cwd,
        home            => $ENV{HOME},
        workspace_roots => [],
        project_roots   => [],
    );
    my $manager = Developer::Dashboard::SkillManager->new( paths => $paths );
    return if !$manager->get_skill_path( $skill_name, include_disabled => 1 );
    return ( $skill_name, $skill_command );
}

# _exec_switchboard_command($path, @argv)
# Replaces the current dashboard process with a resolved built-in helper or
# layered custom command target.
# Input: resolved file or directory path plus argv list for the target command.
# Output: never returns on success; false when the target is not runnable.
sub _exec_switchboard_command {
    my ( $path, @argv ) = @_;
    return 0 if !defined $path || $path eq '';
    # The public entrypoint for this invocation must win over any inherited
    # source-tree path so packaged helpers and shell bootstraps re-enter the
    # current dashboard tree instead of a stale parent environment.
    $ENV{DEVELOPER_DASHBOARD_ENTRYPOINT} = abs_path($0) || $0;
    $ENV{DEVELOPER_DASHBOARD_REPO_LIB} = $dashboard_lib if defined $dashboard_lib && -d $dashboard_lib;

    if ( -d $path ) {
        my $run = _resolve_directory_runner($path);
        if ($run) {
            my @command = command_argv_for_path($run);
            if (is_windows()) {
                system @command, @argv;
                my $status = $?;
                my $exit_code = $status > 255 ? $status >> 8 : $status;
                exit $exit_code;
            }
            exec { $command[0] } @command, @argv;
            die "Unable to exec $run: $!";
        }
    }
    if ( my $command_path = resolve_runnable_file($path) ) {
        my @command = command_argv_for_path($command_path);
        if (is_windows()) {
            system @command, @argv;
            my $status = $?;
            my $exit_code = $status > 255 ? $status >> 8 : $status;
            exit $exit_code;
        }
        exec { $command[0] } @command, @argv;
        die "Unable to exec $command_path: $!";
    }
    return 0;
}

# _command_hook_result_key($path)
# Builds a stable RESULT hash key for one hook path so same-named hooks from
# different layers do not overwrite each other.
# Input: absolute hook path string.
# Output: stable hook result key string.
sub _command_hook_result_key {
    my ($path) = @_;
    my $home = $ENV{HOME} || '';
    return File::Spec->abs2rel( $path, $home ) if $home ne '';
    return $path;
}

# _cli_runtime_layers()
# Resolves the participating CLI roots for DD-OOP-LAYERS in home-to-leaf order.
# Input: none.
# Output: ordered list of CLI root directory paths.
sub _cli_runtime_layers {
    require Developer::Dashboard::PathRegistry;
    my $paths = Developer::Dashboard::PathRegistry->new(
        cwd             => $switchboard_cwd,
        home            => $ENV{HOME},
        workspace_roots => [],
        project_roots   => [],
    );
    return $paths->cli_layers;
}

# _cli_runtime_roots()
# Returns layered CLI roots in deepest-first lookup order for top-level command
# resolution.
# Input: none.
# Output: ordered list of CLI root directory paths.
sub _cli_runtime_roots {
    return reverse _cli_runtime_layers();
}

# _resolve_directory_runner($dir)
# Resolves the runnable entrypoint for one directory-backed command or helper.
# Input: command directory path.
# Output: runnable file path string or undef when no known runner exists.
sub _resolve_directory_runner {
    my ($dir) = @_;
    return if !defined $dir || $dir eq '' || !-d $dir;
    for my $name (qw(run run.pl run.ps1 run.cmd run.bat run.sh run.bash)) {
        my $path = File::Spec->catfile( $dir, $name );
        my $resolved = resolve_runnable_file($path);
        return $resolved if $resolved;
    }
    return;
}

__END__

=head1 NAME

dashboard - thin command switchboard for Developer Dashboard

=head1 SYNOPSIS

  dashboard help
  dashboard init
  dashboard update
  dashboard doctor [--fix]
  dashboard ps1 [--jobs N] [--cwd PATH] [--mode compact|extended] [--color]
  dashboard paths
  dashboard files
  dashboard path list
  dashboard path resolve <name>
  dashboard path add <name> <path>
  dashboard path del <name>
  dashboard path rm <name>
  dashboard path locate <term...>
  dashboard path project-root
  dashboard file list
  dashboard file resolve <name>
  dashboard file add <name> <path>
  dashboard file del <name>
  dashboard file locate <term...>
  dashboard of [--print] [--line N] [--editor CMD] <file|scope> [pattern...]
  dashboard open-file [--print] [--line N] [--editor CMD] <file|scope> [pattern...]
  dashboard workspace [workspace-ref]
  dashboard jq [path] [file]
  dashboard yq [path] [file]
  dashboard tomq [path] [file]
  dashboard propq [path] [file]
  dashboard iniq [path] [file]
  dashboard csvq [path] [file]
  dashboard xmlq [path] [file]
  dashboard encode < input.txt
  dashboard decode < token.txt
  dashboard indicator set <name> <label> <icon> <status>
  dashboard indicator list
  dashboard indicator refresh-core [cwd]
  dashboard collector write-result <name> <exit_code>
  dashboard collector status <name>
  dashboard collector list
  dashboard collector job <name>
  dashboard collector output <name>
  dashboard collector inspect <name>
  dashboard collector log [name]
  dashboard collector run <name>
  dashboard collector start <name>
  dashboard collector stop <name>
  dashboard collector restart <name>
  dashboard housekeeper
  dashboard skills install <git-url-or-local-dir>
  dashboard skills enable <repo-name>
  dashboard skills disable <repo-name>
  dashboard skills uninstall <repo-name>
  dashboard skills update <repo-name>
  dashboard skills list
  dashboard skills usage <repo-name> [-o json|table]
  dashboard which [--edit] <cmd>|<skill>.<cmd>|<skill1>.<skill2>...<cmd>
  dashboard config init
  dashboard config show
  dashboard auth add-user <username> <password>
  dashboard auth list-users
  dashboard auth remove-user <username>
  dashboard page new [id] [title]
  dashboard page save <id>
  dashboard page list
  dashboard page show <id>
  dashboard page encode [id]
  dashboard page decode [token]
  dashboard page urls <id>
  dashboard page render [id|file]
  dashboard page source <id|token>
  dashboard action run <page_id> <action_id>
  dashboard docker compose [--addon NAME] [--mode NAME] [--service NAME] [--project DIR] [--dry-run] <compose-args...>
  dashboard docker list [--enabled|--disabled]
  dashboard docker enable <service>
  dashboard docker disable <service>
      dashboard serve [logs [-f] [-n N]|workers <N>] [--host HOST] [--port PORT] [--workers N] [--foreground] [--no-editor|--no-endit] [--no-indicators|--no-indicator]
  dashboard stop
  dashboard restart [--host HOST] [--port PORT] [--workers N]
  dashboard shell [bash|zsh|sh|ps|powershell|pwsh]
  dashboard version
  dashboard <custom-subcommand> [args...]

=head1 DESCRIPTION

The public C<dashboard> entrypoint is intentionally kept thin.

It only:

=over 4

=item *

bootstraps the repo lib path when running from a checkout

=item *

runs layered per-command hooks across C<DD-OOP-LAYERS>

=item *

stages dashboard-managed built-in helpers under F<~/.developer-dashboard/cli/dd/>

=item *

resolves the effective command from the deepest child layer back to home

=item *

execs the resolved helper or custom command

=back

The real built-in command implementations live outside this entrypoint, either
in private staged helper scripts under F<share/private-cli/> or in reusable
Perl modules loaded by those helpers.

=for comment FULL-POD-DOC START

=head1 PURPOSE

This is the one public executable users invoke. It bootstraps C<PERL5LIB>
when running from a checkout, runs layered command hooks across
C<DD-OOP-LAYERS>, stages dashboard-managed built-in helpers under
F<~/.developer-dashboard/cli/dd/>, and then C<exec>s the final helper or
custom command instead of owning the heavy implementation itself.

=head1 WHY IT EXISTS

It exists to keep the command surface stable while the implementation stays decomposed. The repo rule is that C<dashboard> remains a switchboard, so this file owns dispatch, helper staging, and hook execution rather than data queries, SQL workspaces, page rendering, or other subsystem logic.

=head1 WHEN TO USE

Use this file when you are changing top-level command discovery, the order of command hooks, how built-in helpers are staged, or the deepest-to-home lookup rules for layered custom commands.

=head1 HOW TO USE

Users run C<dashboard E<lt>subcommandE<gt>>. This script resolves whether that
subcommand is a dashboard-managed helper or a user-layer command, primes the
C<RESULT> and C<LAST_RESULT> hook state, honors the explicit C<[[STOP]]>
stderr marker when one hook wants to stop the remaining hook chain, and then
hands off with C<exec>. If behavior belongs to a subsystem, move it into a
module or private helper instead of expanding this file.

=head1 WHAT USES IT

It is used directly by end users, shell bootstraps, private staged helpers that re-enter the public command path, tests that execute dashboard commands, and release/integration smoke runs that verify the installed command surface.

=head1 EXAMPLES

Example 1:

  dashboard paths

Exercise thin command dispatch through one lightweight built-in helper.

Example 2:

  dashboard page source index

Use the public entrypoint to reach a heavier staged helper without embedding that implementation here.

Example 3:

  dashboard serve --foreground

Run the public command through its server bootstrap path while keeping logs in the foreground.

Example 4:

  dashboard which nest.level1.level2.here
  dashboard which --edit jq

Inspect the exact resolved command file and hook chain without running the
command body.

Example 5:

  mkdir -p ~/.developer-dashboard/cli/update.d
  printf '#!/bin/sh\necho first\n' > ~/.developer-dashboard/cli/update.d/00-first
  printf '#!/bin/sh\necho stop >&2; echo "[[STOP]]" >&2\n' > ~/.developer-dashboard/cli/update.d/01-stop
  chmod +x ~/.developer-dashboard/cli/update.d/00-first ~/.developer-dashboard/cli/update.d/01-stop
  dashboard update

Exercise the explicit stop-marker hook path, where one hook ends the remaining
hook chain but control still returns to the main command.

Example 5:

  prove -lv t/30-dashboard-loader.t

Rerun the loader contract test after changing how this public switchboard stages or dispatches helpers.


=for comment FULL-POD-DOC END

=cut
