use strict;
use warnings;

use Cwd qw(abs_path);
use File::Path qw(make_path);
use File::Spec;
use File::Temp qw(tempdir);
use Test::More;
use Time::HiRes qw(sleep);
use URI::Escape qw(uri_escape);

use lib 'lib';

use Developer::Dashboard::ActionRunner;
use Developer::Dashboard::Auth;
use Developer::Dashboard::Config;
use Developer::Dashboard::DockerCompose;
use Developer::Dashboard::FileRegistry;
use Developer::Dashboard::PageDocument;
use Developer::Dashboard::PageResolver;
use Developer::Dashboard::PageStore;
use Developer::Dashboard::PathRegistry;
use Developer::Dashboard::SessionStore;
use Developer::Dashboard::Web::App;

sub _portable_path {
    my ($path) = @_;
    return undef if !defined $path;
    my $resolved = eval { abs_path($path) };
    return defined $resolved && $resolved ne '' ? $resolved : $path;
}

sub is_same_path {
    my ( $got, $expected, $label ) = @_;
    is( _portable_path($got), _portable_path($expected), $label );
}

my $home = tempdir(CLEANUP => 1);
local $ENV{HOME} = $home;
local $ENV{DEVELOPER_DASHBOARD_BOOKMARKS};
local $ENV{DEVELOPER_DASHBOARD_CONFIGS};
local $ENV{DEVELOPER_DASHBOARD_CHECKERS};
chdir $home or die "Unable to chdir to $home: $!";

my $repo = File::Spec->catdir( $home, 'projects', 'demo-app' );
make_path( File::Spec->catdir( $repo, '.git' ) );
open my $compose_fh, '>', File::Spec->catfile( $repo, 'compose.yaml' ) or die $!;
print {$compose_fh} "services:\n  app:\n    image: perl:latest\n";
close $compose_fh;
open my $override_fh, '>', File::Spec->catfile( $repo, 'compose.dev.yaml' ) or die $!;
print {$override_fh} "services:\n  app:\n    environment:\n      MODE: dev\n";
close $override_fh;
open my $repo_cfg, '>', File::Spec->catfile( $repo, '.developer-dashboard.json' ) or die $!;
print {$repo_cfg} <<'JSON';
{
  "docker": {
    "project_overlays": ["compose.project.yaml"],
    "services": {
      "worker": {
        "files": ["compose.worker.yaml"]
      }
    },
    "addons": {
      "mailhog": {
        "files": ["compose.mailhog.yaml"],
        "env": { "MAILHOG_ENABLED": "1" }
      }
    },
    "modes": {
      "dev": {
        "files": ["compose.dev.yaml"],
        "env": { "APP_MODE": "dev" }
      }
    }
  },
  "path_aliases": {
    "repo_alias": "~/projects/demo-app",
    "shared_alias": "~/projects/demo-app"
  },
  "providers": [
    {
      "id": "repo-provider",
      "title": "Repo Provider",
      "body": "from config provider"
    },
    {
      "id": "shared-provider",
      "page": {
        "id": "shared-provider",
        "title": "Shared Provider",
        "layout": { "body": "from config page provider" },
        "actions": [
          { "id": "show_state", "label": "Show State", "kind": "builtin", "builtin": "page.state", "safe": 1 }
        ]
      }
    }
  ]
}
JSON
close $repo_cfg;
open my $addon_fh, '>', File::Spec->catfile( $repo, 'compose.mailhog.yaml' ) or die $!;
print {$addon_fh} "services:\n  mailhog:\n    image: mailhog/mailhog\n";
close $addon_fh;
open my $project_overlay_fh, '>', File::Spec->catfile( $repo, 'compose.project.yaml' ) or die $!;
print {$project_overlay_fh} "services:\n  app:\n    environment:\n      PROJECT_LAYER: 1\n";
close $project_overlay_fh;
open my $service_overlay_fh, '>', File::Spec->catfile( $repo, 'compose.worker.yaml' ) or die $!;
print {$service_overlay_fh} "services:\n  worker:\n    image: perl:latest\n";
close $service_overlay_fh;
my $global_docker_root = File::Spec->catdir( $home, '.developer-dashboard', 'config', 'docker', 'green' );
make_path($global_docker_root);
open my $global_green_fh, '>', File::Spec->catfile( $global_docker_root, 'compose.yml' ) or die $!;
print {$global_green_fh} "services:\n  green:\n    extra_hosts:\n      - host.docker.internal:host-gateway\n";
close $global_green_fh;
open my $global_green_dev_fh, '>', File::Spec->catfile( $global_docker_root, 'development.compose.yml' ) or die $!;
print {$global_green_dev_fh} "services:\n  green:\n    environment:\n      GREEN_DEV: 1\n";
close $global_green_dev_fh;
my $global_blue_root = File::Spec->catdir( $home, '.developer-dashboard', 'config', 'docker', 'blue' );
make_path($global_blue_root);
open my $global_blue_fh, '>', File::Spec->catfile( $global_blue_root, 'compose.yml' ) or die $!;
print {$global_blue_fh} "services:\n  blue:\n    image: alpine\n";
close $global_blue_fh;
open my $global_blue_disabled_fh, '>', File::Spec->catfile( $global_blue_root, 'disabled.yml' ) or die $!;
close $global_blue_disabled_fh;
my $global_purple_root = File::Spec->catdir( $home, '.developer-dashboard', 'config', 'docker', 'purple' );
make_path($global_purple_root);
open my $global_purple_fh, '>', File::Spec->catfile( $global_purple_root, 'compose.yml' ) or die $!;
print {$global_purple_fh} "services:\n  purple:\n    image: alpine\n";
close $global_purple_fh;
my $skill_orange_root = File::Spec->catdir( $home, '.developer-dashboard', 'skills', 'alpha-skill', 'config', 'docker', 'orange' );
make_path($skill_orange_root);
open my $skill_orange_fh, '>', File::Spec->catfile( $skill_orange_root, 'compose.yml' ) or die $!;
print {$skill_orange_fh} "services:\n  orange:\n    image: alpine\n";
close $skill_orange_fh;
open my $skill_orange_env_fh, '>', File::Spec->catfile( $home, '.developer-dashboard', 'skills', 'alpha-skill', '.env' ) or die $!;
print {$skill_orange_env_fh} "ORANGE_SKILL_ENV=orange-skill\nSKILL_ENV_REF=\${DOCKER_SKILL_BASE:-fallback}\n";
close $skill_orange_env_fh;
my $skill_green_root = File::Spec->catdir( $home, '.developer-dashboard', 'skills', 'beta-skill', 'config', 'docker', 'green' );
make_path($skill_green_root);
open my $skill_green_fh, '>', File::Spec->catfile( $skill_green_root, 'compose.yml' ) or die $!;
print {$skill_green_fh} "services:\n  green:\n    environment:\n      GREEN_DEV: skill\n";
close $skill_green_fh;
open my $skill_green_env_fh, '>', File::Spec->catfile( $home, '.developer-dashboard', 'skills', 'beta-skill', '.env' ) or die $!;
print {$skill_green_env_fh} "DISABLED_SKILL_ENV=beta-skill\n";
close $skill_green_env_fh;
open my $skill_green_disabled_fh, '>', File::Spec->catfile( $home, '.developer-dashboard', 'skills', 'beta-skill', '.disabled' ) or die $!;
close $skill_green_disabled_fh;
my $nested_skill_leaf_root = File::Spec->catdir(
    $home, '.developer-dashboard', 'skills', 'foo', 'skills', 'bar', 'skills', 'zzz'
);
my $nested_skill_compose_root = File::Spec->catdir( $nested_skill_leaf_root, 'config', 'docker', 'zzz' );
make_path($nested_skill_compose_root);
open my $nested_skill_compose_fh, '>', File::Spec->catfile( $nested_skill_compose_root, 'compose.yml' ) or die $!;
print {$nested_skill_compose_fh} "services:\n  zzz:\n    image: alpine\n";
close $nested_skill_compose_fh;
open my $nested_skill_root_env_fh, '>', File::Spec->catfile( $home, '.developer-dashboard', 'skills', 'foo', '.env' ) or die $!;
print {$nested_skill_root_env_fh} "VERSION=foo\nSHARED_CHAIN=root\n";
close $nested_skill_root_env_fh;
open my $nested_skill_parent_env_fh, '>', File::Spec->catfile( $home, '.developer-dashboard', 'skills', 'foo', 'skills', 'bar', '.env' ) or die $!;
print {$nested_skill_parent_env_fh} "VERSION=bar\nSHARED_CHAIN=bar\n";
close $nested_skill_parent_env_fh;
open my $nested_skill_leaf_env_fh, '>', File::Spec->catfile( $nested_skill_leaf_root, '.env' ) or die $!;
print {$nested_skill_leaf_env_fh} "VERSION=zzz\nSHARED_CHAIN=zzz\n";
close $nested_skill_leaf_env_fh;
my $local_docker_green_root = File::Spec->catdir( $repo, '.developer-dashboard', 'docker', 'green' );
make_path($local_docker_green_root);
open my $local_green_dev_fh, '>', File::Spec->catfile( $local_docker_green_root, 'development.compose.yml' ) or die $!;
print {$local_green_dev_fh} "services:\n  green:\n    environment:\n      GREEN_DEV: local\n";
close $local_green_dev_fh;

my $paths = Developer::Dashboard::PathRegistry->new(
    home => $home,
    project_roots => [ File::Spec->catdir( $home, 'projects' ) ],
    workspace_roots => [ File::Spec->catdir( $home, 'projects' ) ],
);
my $files = Developer::Dashboard::FileRegistry->new( paths => $paths );
open my $plugin_compose_fh, '>', File::Spec->catfile( $repo, 'compose.debugger.yaml' ) or die $!;
print {$plugin_compose_fh} "services:\n  debugger:\n    image: alpine\n";
close $plugin_compose_fh;

my $old_cwd = Cwd::getcwd();
chdir $repo or die $!;
my $config  = Developer::Dashboard::Config->new( files => $files, paths => $paths );
$paths->register_named_paths( $config->path_aliases );
chdir $old_cwd or die $!;

is( $paths->resolve_dir('repo_alias'), $repo, 'config path alias resolves through path registry' );
is( $paths->resolve_dir('shared_alias'), $repo, 'config path aliases can carry shared extension paths through the path registry' );

my $pages = Developer::Dashboard::PageStore->new( paths => $paths );
my $actions = Developer::Dashboard::ActionRunner->new( files => $files, paths => $paths );
my $resolver = Developer::Dashboard::PageResolver->new(
    actions => $actions,
    config  => $config,
    pages   => $pages,
    paths   => $paths,
);

my @page_ids = $resolver->list_pages;
ok( grep( { $_ eq 'system-status' } @page_ids ), 'builtin provider page is listed' );
ok( grep( { $_ eq 'shared-provider' } @page_ids ), 'config page provider is listed' );
ok( grep( { $_ eq 'repo-provider' } @page_ids ), 'config provider page is listed' );

my $provider_page = $resolver->load_named_page('shared-provider');
is( $provider_page->as_hash->{title}, 'Shared Provider', 'config page provider resolves to a page document' );
is( $provider_page->{meta}{source_kind}, 'provider', 'provider page is marked as provider sourced' );

my $saved_page = Developer::Dashboard::PageDocument->new(
    id          => 'action-page',
    title       => 'Action Page',
    layout      => { body => 'action body [% stash.filter %]' },
    inputs      => [
        { name => 'filter', label => 'Filter', type => 'text' },
    ],
    state       => { alpha => 'one' },
    actions     => [
        { id => 'state', label => 'State', kind => 'builtin', builtin => 'page.state', safe => 1 },
        { id => 'run', label => 'Run', kind => 'command', command => "printf shell-output\n", cwd => $repo },
    ],
    permissions => {},
);
$pages->save_page($saved_page);

my ($builtin_action) = grep { $_->{id} eq 'state' } @{ $saved_page->as_hash->{actions} };
my $builtin_result = $actions->run_page_action(
    action => $builtin_action,
    page   => $saved_page,
    source => 'saved',
);
like( $builtin_result->{body}, qr/"alpha"\s*:\s*"one"/, 'builtin page action returns page state JSON' );

my $source_result = $actions->run_page_action(
    action => { id => 'src', kind => 'builtin', builtin => 'page.source', safe => 1 },
    page   => $saved_page,
    source => 'saved',
);
like( $source_result->{content_type}, qr/text\/plain/, 'builtin page.source action returns instruction content type' );
like( $source_result->{body}, qr/^BOOKMARK:\s+action-page/m, 'builtin page.source action returns canonical instruction text' );

my $paths_result = $actions->run_page_action(
    action => { id => 'paths', kind => 'builtin', builtin => 'paths.list', safe => 1 },
    page   => $saved_page,
    source => 'saved',
);
like( $paths_result->{body}, qr/"runtime"/, 'builtin paths.list action returns runtime data' );

my ($command_action) = grep { $_->{id} eq 'run' } @{ $saved_page->as_hash->{actions} };
my $command_result = $actions->run_page_action(
    action => $command_action,
    page   => $saved_page,
    source => 'saved',
);
is( $command_result->{exit_code}, 0, 'trusted saved page command action executes' );
like( $command_result->{stdout}, qr/shell-output/, 'trusted command action captures stdout' );

my $env_result = $actions->run_command_action(
    command    => 'printf "$ACTION_ENV"',
    cwd        => $repo,
    env        => { ACTION_ENV => 'env-ok' },
    timeout_ms => 1000,
);
like( $env_result->{stdout}, qr/env-ok/, 'command actions inject explicit env values' );

my $timeout_result = $actions->run_command_action(
    command    => "$^X -e 'sleep 2'",
    cwd        => $repo,
    timeout_ms => 200,
);
is( $timeout_result->{exit_code}, 124, 'command action timeout returns timeout exit code' );
ok( $timeout_result->{timed_out}, 'command action timeout is marked' );

my $background_result = $actions->run_command_action(
    command    => "$^X -e 'sleep 1'",
    cwd        => $repo,
    background => 1,
);
ok( $background_result->{pid} > 0, 'background command action returns a child pid' );
ok( $actions->_pid_is_running( $background_result->{pid} ), 'background action child is running initially' );
kill 'TERM', $background_result->{pid};
my $background_wait_loops = $INC{'Devel/Cover.pm'} ? 100 : 20;
for ( 1 .. $background_wait_loops ) {
    last if !$actions->_pid_is_running( $background_result->{pid} );
    sleep 0.1;
}
ok( !$actions->_pid_is_running( $background_result->{pid} ), 'background action child stops cleanly without leaving a direct child for the caller to reap' );

my $transient = Developer::Dashboard::PageDocument->new(
    title       => 'Transient',
    actions     => [
        { id => 'run', label => 'Run', kind => 'command', command => 'printf nope' },
    ],
    permissions => {},
);
my $transient_page = $transient;
eval {
    $actions->run_page_action(
        action => { id => 'run', label => 'Run', kind => 'command', command => 'printf nope' },
        page   => $transient_page,
        source => 'transient',
    );
};
like( $@, qr/not trusted/, 'transient encoded page command action is blocked by default' );

eval {
    $actions->run_page_action(
        action => { id => 'bad', kind => 'weird' },
        page   => $saved_page,
        source => 'saved',
    );
};
like( $@, qr/Unsupported action kind/, 'unsupported action kinds are rejected' );

eval {
    $actions->run_page_action(
        action => { id => 'bad', kind => 'builtin', builtin => 'unknown' },
        page   => $saved_page,
        source => 'saved',
    );
};
like( $@, qr/Unsupported builtin action/, 'unsupported builtin actions are rejected' );

my $transient_allowed = Developer::Dashboard::PageDocument->new(
    title       => 'Transient Allowed',
    actions     => [
        { id => 'run', label => 'Run', kind => 'command', command => 'printf allowed' },
    ],
    permissions => {
        allow_untrusted_actions => 1,
        trusted_actions         => ['run'],
    },
);
my $allowed_page = $transient_allowed;
my $allowed_result = $actions->run_page_action(
    action => { id => 'run', label => 'Run', kind => 'command', command => 'printf allowed' },
    page   => $allowed_page,
    source => 'transient',
);
like( $allowed_result->{stdout}, qr/allowed/, 'transient encoded page can opt in specific trusted actions' );

{
    my $old = Cwd::getcwd();
    chdir $repo or die $!;
    my $docker = Developer::Dashboard::DockerCompose->new(
        config  => $config,
        paths   => $paths,
    );
    local $ENV{DOCKER_SKILL_BASE} = 'skill-base';
    local $ENV{DD_TEST_DOCKER_ROOT} = File::Spec->catdir( $home, '.developer-dashboard', 'config', 'docker' );
    is(
        $docker->_expand_env_path('${DD_TEST_DOCKER_ROOT}/green/compose.yml'),
        File::Spec->catfile( $ENV{DD_TEST_DOCKER_ROOT}, 'green', 'compose.yml' ),
        'docker compose resolver expands defined braced environment variables in configured compose paths',
    );
    is(
        $docker->_expand_env_path('${DD_TEST_DOCKER_MISSING}green/compose.yml'),
        'green/compose.yml',
        'docker compose resolver collapses undefined braced environment variables in configured compose paths',
    );
    is(
        $docker->_expand_env_path('$DD_TEST_DOCKER_ROOT/green/compose.yml'),
        File::Spec->catfile( $ENV{DD_TEST_DOCKER_ROOT}, 'green', 'compose.yml' ),
        'docker compose resolver expands defined bare environment variables in configured compose paths',
    );
    is(
        $docker->_expand_env_path('$DD_TEST_DOCKER_MISSING' . 'green/compose.yml'),
        '/compose.yml',
        'docker compose resolver collapses undefined bare environment variables in configured compose paths',
    );
    my $resolved = $docker->resolve(
        addons => [ 'mailhog' ],
        args   => [ 'config', 'green' ],
        modes  => ['dev'],
        services => [ 'worker', 'orange' ],
    );
    chdir $old or die $!;
    is_same_path( $resolved->{project_root}, $repo, 'docker compose resolver uses current project root' );
    ok( grep( { /compose\.yaml$/ } @{ $resolved->{files} } ), 'docker compose resolver includes discovered base file' );
    ok( grep( { /compose\.project\.yaml$/ } @{ $resolved->{files} } ), 'docker compose resolver includes project overlay' );
    ok( grep( { /compose\.worker\.yaml$/ } @{ $resolved->{files} } ), 'docker compose resolver includes service overlay' );
    ok( grep( { /compose\.dev\.yaml$/ } @{ $resolved->{files} } ), 'docker compose resolver includes mode overlay' );
    ok( grep( { /compose\.mailhog\.yaml$/ } @{ $resolved->{files} } ), 'docker compose resolver includes config addon overlay' );
    ok( grep( { /green\/development\.compose\.yml$/ } @{ $resolved->{files} } ), 'docker compose resolver includes isolated development compose files automatically for selected services' );
    ok( !grep( { /skills\/beta-skill\/config\/docker\/green\/compose\.yml$/ } @{ $resolved->{files} } ), 'docker compose resolver excludes docker roots contributed by disabled skills' );
    is( $resolved->{env}{APP_MODE}, 'dev', 'docker compose resolver merges mode env' );
    is_same_path(
        $resolved->{env}{alpha_skill_DDDC},
        File::Spec->catdir( $home, '.developer-dashboard', 'skills', 'alpha-skill', 'config', 'docker' ),
        'docker compose resolver exports one skill-specific DDDC variable for each participating skill docker root',
    );
    is( $resolved->{env}{ORANGE_SKILL_ENV}, 'orange-skill', 'docker compose resolver loads .env from participating skill docker roots' );
    is( $resolved->{env}{SKILL_ENV_REF}, 'skill-base', 'docker compose resolver expands participating skill .env values against the current process environment' );
    ok( !exists $resolved->{env}{DISABLED_SKILL_ENV}, 'docker compose resolver skips .env from disabled skill docker roots' );
    ok( grep( { /skills\/alpha-skill\/\.env$/ } @{ $resolved->{env_files} } ), 'docker compose resolver reports participating skill .env files in dry-run data' );
    is_same_path( $resolved->{env}{DDDC}, File::Spec->catdir( $repo, '.developer-dashboard', 'config', 'docker' ), 'docker compose resolver exports DDDC as the effective project-local docker config root' );
    is( $resolved->{env}{MAILHOG_ENABLED}, '1', 'docker compose resolver merges addon env' );
    is_deeply( [ @{ $resolved->{command} }[0,1] ], [ 'docker', 'compose' ], 'docker compose resolver produces docker compose command' );
    is_deeply( $resolved->{precedence}, [ qw(base project service addon mode) ], 'docker compose resolver exposes overlay precedence' );
    is_same_path(
        ( grep { /green\/development\.compose\.yml$/ } @{ $resolved->{files} } )[-1],
        File::Spec->catfile( $repo, '.developer-dashboard', 'docker', 'green', 'development.compose.yml' ),
        'docker compose resolver leaves the deepest project-local isolated service folder as the last overriding development compose layer',
    );
    ok( grep( { $_ eq 'green' } @{ $resolved->{services} } ), 'docker compose resolver infers service names from passthrough docker compose args' );
    is( $resolved->{command}[-1], 'green', 'docker compose resolver preserves passthrough docker compose service args' );
}

{
    my $old = Cwd::getcwd();
    chdir $repo or die $!;
    my $docker = Developer::Dashboard::DockerCompose->new(
        config  => $config,
        paths   => $paths,
    );
    local $ENV{ORANGE_SKILL_ENV};
    my $resolved = $docker->resolve(
        args => ['config'],
    );
    chdir $old or die $!;
    ok( grep( { $_ eq 'green' } @{ $resolved->{services} } ), 'docker compose resolver auto-loads isolated services by default when no service is specified' );
    ok( grep( { $_ eq 'orange' } @{ $resolved->{services} } ), 'docker compose resolver auto-loads isolated services contributed by installed skills' );
    ok( grep( { $_ eq 'purple' } @{ $resolved->{services} } ), 'docker compose resolver auto-loads isolated services without requiring activation markers' );
    ok( !grep( { $_ eq 'blue' } @{ $resolved->{services} } ), 'docker compose resolver skips isolated services marked disabled' );
    ok( grep( { /green\/development\.compose\.yml$/ } @{ $resolved->{files} } ), 'docker compose resolver includes isolated development compose files during plain docker compose passthrough' );
    ok( !grep( { /skills\/beta-skill\/config\/docker\/green\/compose\.yml$/ } @{ $resolved->{files} } ), 'docker compose passthrough excludes compose roots contributed by disabled skills' );
    ok( grep( { /skills\/alpha-skill\/config\/docker\/orange\/compose\.yml$/ } @{ $resolved->{files} } ), 'docker compose resolver includes skill docker compose roots during plain docker compose passthrough' );
    ok( grep( { /purple\/compose\.yml$/ } @{ $resolved->{files} } ), 'docker compose resolver includes non-disabled isolated compose folders during plain docker compose passthrough' );
    ok( !grep( { /blue\/compose\.yml$/ } @{ $resolved->{files} } ), 'docker compose resolver does not include disabled isolated compose folders' );
    is_same_path(
        $resolved->{env}{alpha_skill_DDDC},
        File::Spec->catdir( $home, '.developer-dashboard', 'skills', 'alpha-skill', 'config', 'docker' ),
        'docker compose resolver auto-loads one skill-specific DDDC variable for auto-discovered skill services',
    );
    is( $resolved->{env}{ORANGE_SKILL_ENV}, 'orange-skill', 'docker compose resolver auto-loads participating skill .env values alongside auto-discovered services' );
    ok( !exists $resolved->{env}{DISABLED_SKILL_ENV}, 'docker compose resolver still skips disabled skill .env values during auto-discovery' );
    ok( !defined $ENV{ORANGE_SKILL_ENV}, 'docker compose resolver does not leak participating skill .env values into the caller environment' );
    is( $resolved->{command}[-1], 'config', 'docker compose resolver preserves passthrough config invocation with active auto-discovery' );
}

{
    my $old = Cwd::getcwd();
    chdir $repo or die $!;
    my $docker = Developer::Dashboard::DockerCompose->new(
        config  => $config,
        paths   => $paths,
    );
    my $resolved = $docker->resolve(
        services => ['zzz'],
        args     => ['config'],
    );
    chdir $old or die $!;
    is( $resolved->{env}{VERSION}, 'zzz', 'docker compose resolver lets the leaf nested skill .env win for overlapping keys' );
    is( $resolved->{env}{foo_VERSION}, 'foo', 'docker compose resolver preserves the root nested skill .env value under the root skill alias' );
    is( $resolved->{env}{foo_bar_VERSION}, 'bar', 'docker compose resolver preserves the parent nested skill .env value under the cumulative parent alias' );
    is( $resolved->{env}{SHARED_CHAIN}, 'zzz', 'docker compose resolver loads nested skill env files from root to leaf so the leaf override wins' );
    is_same_path(
        $resolved->{env}{zzz_DDDC},
        File::Spec->catdir( $nested_skill_leaf_root, 'config', 'docker' ),
        'docker compose resolver exports the leaf nested skill DDDC alias for a participating nested skill compose root',
    );
    is_same_path(
        $resolved->{env}{foo_bar_zzz_DDDC},
        File::Spec->catdir( $nested_skill_leaf_root, 'config', 'docker' ),
        'docker compose resolver exports the cumulative nested skill DDDC alias for a participating nested skill compose root',
    );
    ok( grep( { /skills\/foo\/\.env$/ } @{ $resolved->{env_files} } ), 'docker compose resolver reports the root nested skill .env file when a nested compose service participates' );
    ok( grep( { /skills\/foo\/skills\/bar\/\.env$/ } @{ $resolved->{env_files} } ), 'docker compose resolver reports the parent nested skill .env file when a nested compose service participates' );
    ok( grep( { /skills\/foo\/skills\/bar\/skills\/zzz\/\.env$/ } @{ $resolved->{env_files} } ), 'docker compose resolver reports the leaf nested skill .env file when a nested compose service participates' );
}

{
    my $old = Cwd::getcwd();
    chdir $repo or die $!;
    my $docker = Developer::Dashboard::DockerCompose->new(
        config  => $config,
        paths   => $paths,
    );

    ok( !$docker->_service_folder_is_disabled( project_root => $repo, service => 'purple' ), 'project-local docker service starts enabled when no local marker exists' );

    my $disable = $docker->disable_service(
        project_root => $repo,
        service      => 'purple',
    );
    is( $disable->{service}, 'purple', 'disable_service reports the toggled service name' );
    is( $disable->{disabled}, 1, 'disable_service reports the service as disabled' );
    is_same_path(
        $disable->{marker},
        File::Spec->catfile( $repo, '.developer-dashboard', 'docker', 'purple', 'disabled.yml' ),
        'disable_service writes the marker into the deepest runtime docker root',
    );
    ok( -f $disable->{marker}, 'disable_service creates the disabled.yml marker file' );
    ok( $docker->_service_folder_is_disabled( project_root => $repo, service => 'purple' ), 'project-local marker disables an inherited home docker service' );

    my $enable = $docker->enable_service(
        project_root => $repo,
        service      => 'purple',
    );
    is( $enable->{service}, 'purple', 'enable_service reports the toggled service name' );
    is( $enable->{disabled}, 0, 'enable_service reports the service as enabled' );
    is_same_path(
        $enable->{marker},
        File::Spec->catfile( $repo, '.developer-dashboard', 'docker', 'purple', 'disabled.yml' ),
        'enable_service reports the same local marker path it removed',
    );
    ok( !-f $enable->{marker}, 'enable_service removes the local disabled.yml marker file' );
    ok( !$docker->_service_folder_is_disabled( project_root => $repo, service => 'purple' ), 'enable_service re-enables the inherited home docker service' );

    my $all_services = $docker->list_services(
        project_root => $repo,
    );
    is_deeply(
        [ map { $_->{service} } @{$all_services} ],
        [ qw(blue green orange purple worker zzz) ],
        'list_services returns isolated docker services in sorted order including nested skill compose services',
    );
    is_deeply(
        {
            map { $_->{service} => $_->{disabled} } @{$all_services}
        },
        {
            blue   => 1,
            green  => 0,
            orange => 0,
            purple => 0,
            worker => 0,
            zzz    => 0,
        },
        'list_services reports enabled and disabled service state including nested skill compose services',
    );
    is_deeply(
        [ map { $_->{service} } @{ $docker->list_services( project_root => $repo, filter => 'enabled' ) } ],
        [ qw(green orange purple worker zzz) ],
        'list_services filter enabled keeps only enabled services including nested skill compose services',
    );
    is_deeply(
        [ map { $_->{service} } @{ $docker->list_services( project_root => $repo, filter => 'disabled' ) } ],
        [qw(blue)],
        'list_services filter disabled keeps only disabled services',
    );

    $docker->disable_service(
        project_root => $repo,
        service      => 'purple',
    );
    is_deeply(
        [ map { $_->{service} } @{ $docker->list_services( project_root => $repo, filter => 'disabled' ) } ],
        [ qw(blue purple) ],
        'list_services reflects newly disabled services from the deepest runtime layer',
    );

    chdir $old or die $!;
}

my $auth = Developer::Dashboard::Auth->new( files => $files, paths => $paths );
my $sessions = Developer::Dashboard::SessionStore->new( paths => $paths );
my $app = Developer::Dashboard::Web::App->new(
    actions  => $actions,
    auth     => $auth,
    pages    => $pages,
    resolver => $resolver,
    sessions => $sessions,
);

my ( $provider_code, undef, $provider_body ) = @{ $app->handle( path => '/app/shared-provider', query => '', remote_addr => '127.0.0.1', headers => { host => '127.0.0.1' } ) };
is( $provider_code, 200, 'provider page renders through web app' );
like( $provider_body, qr/Shared Provider/, 'provider page content is rendered' );

my ( $state_render_code, undef, $state_render_body ) = @{ $app->handle( path => '/app/action-page', query => 'filter=active', remote_addr => '127.0.0.1', headers => { host => '127.0.0.1' } ) };
is( $state_render_code, 200, 'saved page render with query state succeeds' );
like( $state_render_body, qr/active/, 'query parameters are reflected into page state rendering' );

my $atoken = $actions->encode_action_payload(
    action => $saved_page->as_hash->{actions}[0],
    page   => $saved_page,
    source => 'saved',
);
my ( $encoded_action_code, $encoded_action_type, $encoded_action_body ) = @{ $app->handle(
    path        => '/action',
    method      => 'POST',
    query       => 'atoken=' . uri_escape($atoken),
    remote_addr => '127.0.0.1',
    headers     => { host => '127.0.0.1' },
) };
is( $encoded_action_code, 403, 'encoded action route is denied by default' );
like( $encoded_action_type, qr/text\/plain/, 'encoded action route denial returns plain text' );
like( $encoded_action_body, qr/Transient token URLs are disabled/, 'encoded action route denial explains the policy' );

{
    local $ENV{DEVELOPER_DASHBOARD_ALLOW_TRANSIENT_URLS} = 1;
    my ( $allowed_encoded_action_code, $allowed_encoded_action_type, $allowed_encoded_action_body ) = @{ $app->handle(
        path        => '/action',
        method      => 'POST',
        query       => 'atoken=' . uri_escape($atoken),
        remote_addr => '127.0.0.1',
        headers     => { host => '127.0.0.1' },
    ) };
    is( $allowed_encoded_action_code, 200, 'encoded action route executes when transient token URLs are enabled' );
    like( $allowed_encoded_action_type, qr/application\/json/, 'encoded action route returns json when transient token URLs are enabled' );
    like( $allowed_encoded_action_body, qr/"alpha"\s*:\s*"one"/, 'encoded action route returns page state when transient token URLs are enabled' );
}

my $transient_safe = Developer::Dashboard::PageDocument->new(
    title   => 'Transient Safe',
    actions => [
        { id => 'state', label => 'State', kind => 'builtin', builtin => 'page.state', safe => 1 },
    ],
    state => { beta => 'two' },
);
my $token = $actions->encode_action_payload(
    action => { id => 'state', label => 'State', kind => 'builtin', builtin => 'page.state', safe => 1 },
    page   => $transient_safe,
    source => 'transient',
);
my ( $transient_blocked_code, undef, $transient_blocked_body ) = @{ $app->handle(
    path        => '/action',
    method      => 'POST',
    query       => 'atoken=' . uri_escape($token),
    remote_addr => '127.0.0.1',
    headers     => { host => '127.0.0.1' },
) };
is( $transient_blocked_code, 403, 'transient encoded builtin action route is denied by default' );
like( $transient_blocked_body, qr/Transient token URLs are disabled/, 'transient encoded builtin action denial explains the policy' );

{
    local $ENV{DEVELOPER_DASHBOARD_ALLOW_TRANSIENT_URLS} = 1;
    my ( $transient_code, $transient_type, $transient_body ) = @{ $app->handle(
        path        => '/action',
        method      => 'POST',
        query       => 'atoken=' . uri_escape($token),
        remote_addr => '127.0.0.1',
        headers     => { host => '127.0.0.1' },
    ) };
    is( $transient_code, 200, 'transient encoded builtin action route executes for safe actions when transient token URLs are enabled' );
    like( $transient_type, qr/application\/json/, 'transient encoded builtin action route returns json when transient token URLs are enabled' );
    like( $transient_body, qr/"beta"\s*:\s*"two"/, 'transient encoded builtin action route returns action output when transient token URLs are enabled' );
}

my ( $missing_action_code ) = @{ $app->handle(
    path        => '/app/action-page/action/missing',
    method      => 'POST',
    remote_addr => '127.0.0.1',
    headers     => { host => '127.0.0.1' },
) };
is( $missing_action_code, 404, 'missing page actions return not found' );

my $app_without_actions = Developer::Dashboard::Web::App->new(
    auth     => $auth,
    pages    => $pages,
    resolver => $resolver,
    sessions => $sessions,
);
my ( $no_runner_code ) = @{ $app_without_actions->handle(
    path        => '/app/action-page/action/state',
    method      => 'POST',
    remote_addr => '127.0.0.1',
    headers     => { host => '127.0.0.1' },
) };
is( $no_runner_code, 501, 'web action routes return not implemented when no action runner is configured' );

$auth->add_user( username => 'helper', password => 'helper-pass-123', role => 'helper' );
my @users_before_remove = $auth->list_users;
ok( scalar(@users_before_remove), 'helper users can be listed before removal' );
$auth->remove_user('helper');
my @users_after_remove = $auth->list_users;
is( scalar(@users_after_remove), 0, 'remove_user deletes helper records' );

done_testing;

__END__

=head1 NAME

10-extension-action-docker.t - extension, action, and docker resolver tests

=head1 DESCRIPTION

This test verifies config-driven extensions, page actions, encoded action
transport, and docker compose resolution behavior.

=for comment FULL-POD-DOC START

=head1 PURPOSE

This test is the executable regression contract for extension resolution, action transport, and Docker-aware runtime behavior. Read it when you need to understand the real fixture setup, assertions, and failure modes for this slice of the repository instead of guessing from the module names alone.

=head1 WHY IT EXISTS

It exists because extension resolution, action transport, and Docker-aware runtime behavior has enough moving parts that a code-only review can miss real regressions. Keeping those expectations in a dedicated test file makes the TDD loop, coverage loop, and release gate concrete.

=head1 WHEN TO USE

Use this file when changing extension resolution, action transport, and Docker-aware runtime behavior, when a focused CI failure points here, or when you want a faster regression loop than running the entire suite.

=head1 HOW TO USE

Run it directly with C<prove -lv t/10-extension-action-docker.t> while iterating, then keep it green under C<prove -lr t> and the coverage runs before release. 

=head1 WHAT USES IT

Developers during TDD, the full C<prove -lr t> suite, the coverage gates, and the release verification loop all rely on this file to keep this behavior from drifting.

=head1 EXAMPLES

Example 1:

  prove -lv t/10-extension-action-docker.t

Run the focused regression test by itself while you are changing the behavior it owns.

Example 2:

  HARNESS_PERL_SWITCHES=-MDevel::Cover prove -lv t/10-extension-action-docker.t

Exercise the same focused test while collecting coverage for the library code it reaches.

Example 3:

  prove -lr t

Put the focused fix back through the whole repository suite before calling the work finished.

=for comment FULL-POD-DOC END

=cut
