package Developer::Dashboard::Web::App;

use strict;
use warnings;

our $VERSION = '3.90';

use Capture::Tiny qw(capture);
use File::Basename qw(dirname);
use File::ShareDir qw(dist_dir);
use POSIX qw(strftime);
use File::Spec;
use Scalar::Util qw(blessed);
use URI;
use URI::Escape qw(uri_unescape);
use Cwd qw(cwd);

use Developer::Dashboard::JSON qw(json_encode);
use Developer::Dashboard::Platform qw(command_in_path);
use Developer::Dashboard::PageDocument;
use Developer::Dashboard::PageRuntime;
use Developer::Dashboard::Codec qw(decode_payload);
use Developer::Dashboard::Zipper ();

our $MODULE_SOURCE_PATH = File::Spec->rel2abs(__FILE__);

# new(%args)
# Constructs the browser-facing dashboard web application.
# Input: auth, pages, sessions, config, and optional actions/resolver objects.
# Output: Developer::Dashboard::Web::App object.
sub new {
    my ( $class, %args ) = @_;
    my $auth     = $args{auth}     || die 'Missing auth store';
    my $pages    = $args{pages}    || die 'Missing page store';
    my $sessions = $args{sessions} || die 'Missing session store';
    my $runtime = $args{runtime};
    if ( !$runtime ) {
        $runtime = Developer::Dashboard::PageRuntime->new(
            paths => ref($pages) eq 'Developer::Dashboard::PageStore' ? $pages->{paths} : undef,
        );
    }
    return bless {
        actions  => $args{actions},
        auth     => $auth,
        config   => $args{config},
        pages    => $pages,
        prompt   => $args{prompt},
        runtime  => $runtime,
        resolver => $args{resolver},
        sessions => $sessions,
    }, $class;
}

# _transient_url_tokens_allowed()
# Reports whether tokenized transient web execution is enabled by environment.
# Input: none.
# Output: boolean true when DEVELOPER_DASHBOARD_ALLOW_TRANSIENT_URLS enables transient web tokens.
sub _transient_url_tokens_allowed {
    return defined $ENV{DEVELOPER_DASHBOARD_ALLOW_TRANSIENT_URLS}
      && $ENV{DEVELOPER_DASHBOARD_ALLOW_TRANSIENT_URLS} =~ /\A(?:1|true|yes|on)\z/i;
}

# _transient_url_forbidden_response()
# Builds the default-deny response for tokenized transient web execution.
# Input: none.
# Output: response array reference with a 403 plain-text error.
sub _transient_url_forbidden_response {
    return [
        403,
        'text/plain; charset=utf-8',
        "Transient token URLs are disabled. Save the page as a bookmark file or set DEVELOPER_DASHBOARD_ALLOW_TRANSIENT_URLS=1.\n",
    ];
}

# _editor_disabled()
# Reports whether the web service is running in read-only no-editor mode.
# Input: none.
# Output: boolean true when bookmark editing and source views are disabled.
sub _editor_disabled {
    my ($self) = @_;
    return 0 if !blessed( $self->{config} ) || !$self->{config}->can('web_settings');
    my $settings = $self->{config}->web_settings;
    return $settings->{no_editor} ? 1 : 0;
}

# _indicators_disabled()
# Reports whether the web service is running with the top-right browser chrome hidden.
# Input: none.
# Output: boolean true when the right-top browser indicators/context area is disabled.
sub _indicators_disabled {
    my ($self) = @_;
    return 0 if !blessed( $self->{config} ) || !$self->{config}->can('web_settings');
    my $settings = $self->{config}->web_settings;
    return $settings->{no_indicators} ? 1 : 0;
}

# _no_editor_response()
# Builds the read-only denial response used when bookmark editing is disabled.
# Input: none.
# Output: response array reference with a 403 plain-text error.
sub _no_editor_response {
    return [
        403,
        'text/plain; charset=utf-8',
        "Dashboard serve is running in read-only no-editor mode.\n",
    ];
}

# handle(%args)
# Dispatches one normalized request through the service-side route map.
# Input: path, query, method, headers, body, and remote address.
# Output: array reference of status code, content type, body, and optional headers hash.
sub handle {
    my ( $self, %args ) = @_;
    my $path   = $args{path} || '/';
    my $method = uc( $args{method} || 'GET' );

    if ( $path eq '/login' && $method eq 'POST' ) {
        return $self->login_response(%args);
    }
    if ( $path eq '/logout' ) {
        return $self->logout_response(%args);
    }

    my $auth_response = $self->authorize_request(%args);
    return $auth_response if $auth_response;

    return $self->dispatch_request(%args);
}

# authorize_request(%args)
# Authenticates one browser request and seeds the per-request context.
# Input: normalized request path, headers, and remote address.
# Output: undef when authorized, otherwise an HTTP response array reference.
sub authorize_request {
    my ( $self, %args ) = @_;
    my $headers = $args{headers} || {};
    my $config_has_web_settings = blessed( $self->{config} ) && $self->{config}->can('web_settings');
    my $tier = $self->{auth}->trust_tier(
        remote_addr          => $args{remote_addr},
        host                 => $headers->{host},
        extra_loopback_hosts => (
            $config_has_web_settings
            ? ( $self->{config}->web_settings->{ssl_subject_alt_names} || [] )
            : []
        ),
    );
    my $session;

    if ( $tier ne 'admin' ) {
        return $self->_helper_access_disabled_response
          if !$self->{auth}->helper_users_enabled;
        $session = $self->{sessions}->from_cookie( $headers->{cookie}, remote_addr => $args{remote_addr} );
        if ( !$session ) {
            return [
                401,
                'text/html; charset=utf-8',
                $self->{auth}->login_page(
                    redirect_to => $self->_login_redirect_target(%args),
                ),
            ];
        }
    }

    $self->{_current_request_context} = {
        tier        => $tier,
        remote_addr => $args{remote_addr} || '',
        host        => $headers->{host} || '',
        username    => ref($session) eq 'HASH' ? ( $session->{username} || '' ) : '',
        role        => ref($session) eq 'HASH' ? ( $session->{role} || '' ) : '',
    };
    return;
}

# dispatch_request(%args)
# Routes one authorized normalized request to the matching service method.
# Input: path, query, method, headers, body, and remote address.
# Output: array reference of status code, content type, body, and optional headers hash.
sub dispatch_request {
    my ( $self, %args ) = @_;
    my $path   = $args{path} || '/';
    my $method = uc( $args{method} || 'GET' );

    return $self->root_response(%args) if $path eq '/';
    return $self->apps_redirect_response(%args) if $path eq '/apps';
    return $self->legacy_ajax_response(%args) if $path eq '/ajax';
    return $self->ajax_singleton_stop_response(%args) if $path eq '/ajax/singleton/stop';
    if ( $path =~ m{^/ajax/(.+)$} ) {
        return $self->prefixed_ajax_file_response( ajax_path => uri_unescape($1), %args );
    }
    return $self->status_response(%args) if $path eq '/system/status';
    return $self->jquery_js_response(%args) if $path eq '/js/jquery.js' || $path eq '/js/jquery-4.0.0.min.js';
    return $self->marked_js_response(%args) if $path eq '/marked.min.js';
    return $self->tiff_js_response(%args) if $path eq '/tiff.min.js';
    return $self->loading_image_response(%args) if $path eq '/loading.webp';
    if ( $path =~ m{^/(js|css|others)/(.+)$} ) {
        return $self->prefixed_static_file_response( type => $1, file => uri_unescape($2), %args );
    }
    if ( $path =~ m{^/app/(.+)/source$} ) {
        return $self->page_source_response( id => $1, %args );
    }
    if ( $path =~ m{^/app/(.+)/edit$} && $method eq 'POST' ) {
        return $self->page_edit_post_response( id => $1, %args );
    }
    if ( $path =~ m{^/app/(.+)/edit$} ) {
        return $self->page_edit_response( id => $1, %args );
    }
    if ( $path =~ m{^/app/(.+)/action/([^/]+)$} && $method eq 'POST' ) {
        return $self->page_action_response( id => $1, action_id => $2, %args );
    }
    if ( $path =~ m{^/app/(.+)$} ) {
        return $self->legacy_app_response( id => $1, %args );
    }
    if ( $path =~ m{^/skill/([^/]+)/(.+)$} ) {
        return $self->skill_route_response( skill_name => $1, route => $2, %args );
    }
    return $self->transient_action_response(%args) if $path eq '/action' && $method eq 'POST';
    if ( my $custom_route = $self->_custom_skill_route_response( %args, route_path => $path ) ) {
        return $custom_route;
    }

    return [ 404, 'text/plain; charset=utf-8', "Not found\n" ];
}

# _helper_access_disabled_response()
# Builds the outsider-access denial used before any helper user exists.
# Input: none.
# Output: response array reference with a bare 401 plain-text error body.
sub _helper_access_disabled_response {
    return [
        401,
        'text/plain; charset=utf-8',
        '',
    ];
}

# _handle_login(%args)
# Processes helper login form submissions and issues a session cookie.
# Input: request body and remote address.
# Output: response array reference.
sub _handle_login {
    my ( $self, %args ) = @_;
    my %form = _parse_query( $args{body} );
    my $redirect_to = $self->_sanitize_redirect_target( $form{redirect_to} );
    return $self->_helper_access_disabled_response
      if !$self->{auth}->helper_users_enabled;
    my $user = $self->{auth}->verify_user(
        username => $form{username},
        password => $form{password},
    );

    if ( !$user ) {
        return [
            401,
            'text/html; charset=utf-8',
            $self->{auth}->login_page(
                message     => 'Invalid username or password.',
                redirect_to => $redirect_to,
            ),
        ];
    }

    my $session = $self->{sessions}->create(
        username    => $user->{username},
        role        => $user->{role},
        remote_addr => $args{remote_addr},
    );

    return [
        302,
        'text/plain; charset=utf-8',
        "Redirecting\n",
        {
            'Location'   => $redirect_to || '/',
            'Set-Cookie' => _session_cookie( $session->{session_id} ),
        },
    ];
}

# login_response(%args)
# Executes the helper login submission route.
# Input: normalized request body and remote address.
# Output: response array reference.
sub login_response {
    my ( $self, %args ) = @_;
    return $self->_handle_login(
        body        => defined $args{body} ? $args{body} : '',
        remote_addr => $args{remote_addr},
    );
}

# logout_response(%args)
# Executes the logout route and expires any active helper session.
# Input: normalized request headers and remote address.
# Output: response array reference.
sub logout_response {
    my ( $self, %args ) = @_;
    my $headers = $args{headers} || {};
    my $session = $self->{sessions}->from_cookie( $headers->{cookie}, remote_addr => $args{remote_addr} );
    if ($session) {
        if ( ( $session->{role} || '' ) eq 'helper' && ( $session->{username} || '' ) ne '' ) {
            $self->{auth}->remove_user( $session->{username} );
        }
        $self->{sessions}->delete( $session->{session_id} );
    }
    return [
        302,
        'text/plain; charset=utf-8',
        "Redirecting\n",
        {
            'Location'   => '/login',
            'Set-Cookie' => _expired_session_cookie(),
        },
    ];
}

# root_response(%args)
# Executes the root route for saved index redirects, blank editor, transient, and saved-from-root pages.
# Input: normalized request path, query, body, headers, and remote address.
# Output: response array reference.
sub root_response {
    my ( $self, %args ) = @_;
    my ( $params, $body_params ) = $self->_request_params(%args);
    my $path = $args{path} || '/';
    my $headers = $args{headers} || {};

    if ( exists $body_params->{instruction} || exists $params->{instruction} ) {
        return _no_editor_response() if $self->_editor_disabled;
        my $instruction = exists $body_params->{instruction} ? $body_params->{instruction} : $params->{instruction};
        my $page = Developer::Dashboard::PageDocument->from_instruction($instruction);
        $page->{meta}{raw_instruction} = $instruction;
        my $page_id = $page->as_hash->{id} || '';
        if ( $page_id eq '' && !_transient_url_tokens_allowed() ) {
            return _transient_url_forbidden_response();
        }
        my $source_kind = 'transient';
        if ( exists $body_params->{instruction} && $page_id ne '' ) {
            $self->{pages}->save_page($page);
            $source_kind = 'saved';
        }
        $page->{meta}{source_kind} = $source_kind;
        my $mode = $params->{mode} || $body_params->{mode} || 'edit';
        $page = $self->_page_with_runtime_state(
            $page,
            query_params => $params,
            body_params  => $body_params,
            path         => $path,
            remote_addr  => $args{remote_addr},
            headers      => $headers,
        );
        $page = $self->{runtime}->prepare_page(
            page            => $page,
            source          => $source_kind,
            runtime_context => { params => { %{$params}, %{$body_params} } },
        );
        return $self->_page_response( $page, $mode );
    }

    if ( my $token = $params->{token} ) {
        return _transient_url_forbidden_response() if !_transient_url_tokens_allowed();
        my $page = $self->{pages}->load_transient_page($token);
        $page->{meta}{raw_instruction} = $page->canonical_instruction;
        my $mode = $params->{mode} || 'edit';
        return _no_editor_response() if $self->_editor_disabled && $mode eq 'source';
        $mode = 'render' if $self->_editor_disabled && $mode eq 'edit';
        $page = $self->_page_with_runtime_state(
            $page,
            query_params => $params,
            body_params  => $body_params,
            path         => $path,
            remote_addr  => $args{remote_addr},
            headers      => $headers,
        );
        $page = $self->{runtime}->prepare_page(
            page            => $page,
            source          => 'transient',
            runtime_context => { params => { %{$params}, %{$body_params} } },
        );
        return $self->_page_response( $page, $mode );
    }

    if ( $self->_saved_page_exists('index') ) {
        return [
            302,
            'text/plain; charset=utf-8',
            "Redirecting\n",
            { Location => '/app/index' },
        ];
    }

    return _no_editor_response() if $self->_editor_disabled;
    return $self->_blank_editor_response;
}

# apps_redirect_response(%args)
# Executes the `/apps` compatibility redirect route.
# Input: normalized request arguments.
# Output: response array reference.
sub apps_redirect_response {
    my ( $self, %args ) = @_;
    return [
        302,
        'text/plain; charset=utf-8',
        "Redirecting\n",
        { Location => '/app/index' },
    ];
}

# legacy_ajax_response(%args)
# Executes the `/ajax` route using query and body parameters from one request.
# Input: normalized request query, body, headers, and remote address.
# Output: response array reference.
sub legacy_ajax_response {
    my ( $self, %args ) = @_;
    my ( $params, $body_params ) = $self->_request_params(%args);
    my %request_params = ( %{$params}, %{$body_params} );
    return _transient_url_forbidden_response() if !$self->_legacy_ajax_allowed( \%request_params );
    return $self->_legacy_ajax_response(
        params       => \%request_params,
        remote_addr  => $args{remote_addr},
        headers      => $args{headers} || {},
    );
}

# legacy_ajax_file_response(%args)
# Executes one `/ajax/<file>` compatibility route against a saved ajax file.
# Input: ajax file name plus normalized request query, body, headers, and remote address.
# Output: response array reference.
sub legacy_ajax_file_response {
    my ( $self, %args ) = @_;
    my $ajax_file = $args{ajax_file} || '';
    my ( $params, $body_params ) = $self->_request_params(%args);
    my %request_params = ( %{$params}, %{$body_params}, file => $ajax_file );
    return _transient_url_forbidden_response() if !$self->_legacy_ajax_allowed( \%request_params );
    return $self->_legacy_ajax_response(
        params       => \%request_params,
        remote_addr  => $args{remote_addr},
        headers      => $args{headers} || {},
    );
}

# skill_ajax_file_response(%args)
# Executes one `/ajax/<skill>/<file>` route against a layered skill-local ajax file.
# Input: skill name, ajax file name, and normalized request metadata.
# Output: response array reference.
sub skill_ajax_file_response {
    my ( $self, %args ) = @_;
    my $skill_name = $args{skill_name} || '';
    my $ajax_file  = $args{ajax_file}  || '';
    return [ 400, 'text/plain; charset=utf-8', "Invalid skill name\n" ] if $skill_name eq '';
    my %params = _parse_query( $args{query} || '' );
    my %body_params = _parse_query( $args{body} || '' );
    my $saved_ajax_path = $self->_skill_ajax_file_path( $skill_name, $ajax_file );
    my %request_params = (
        %params,
        %body_params,
        file => $saved_ajax_path ne '' ? $ajax_file : join( '/', $skill_name, $ajax_file ),
    );
    if ( !exists $request_params{type} && ( $args{default_type} || '' ) ne '' ) {
        $request_params{type} = $args{default_type};
    }
    return _transient_url_forbidden_response() if !$self->_legacy_ajax_allowed( \%request_params );
    return $self->_legacy_ajax_response(
        params          => \%request_params,
        saved_ajax_path => $saved_ajax_path,
    );
}

# prefixed_ajax_file_response(%args)
# Resolves one `/ajax/...` request by preferring the longest matching skill prefix
# and falling back to the normal nested saved-ajax file path when no skill-local
# handler exists.
# Input: ajax_path plus normalized request metadata.
# Output: response array reference.
sub prefixed_ajax_file_response {
    my ( $self, %args ) = @_;
    my $ajax_path = $args{ajax_path} || '';
    my @segments = grep { defined && $_ ne '' } split m{/+}, $ajax_path;
    if ( my $spec = $self->_resolve_skill_route_spec(@segments) ) {
        my $ajax_file = join '/', @{ $spec->{route_segments} || [] };
        if ( $ajax_file ne '' ) {
            my $saved_ajax_path = $self->_skill_ajax_file_path( $spec->{skill_name}, $ajax_file );
            my $route_spec = $self->_skill_ajax_route_spec( $spec->{skill_name}, $ajax_file );
            return $self->skill_ajax_file_response(
                %args,
                default_type => $route_spec ? ( $route_spec->{type} || '' ) : '',
                skill_name   => $spec->{skill_name},
                ajax_file    => $ajax_file,
            ) if $saved_ajax_path ne '';
        }
    }
    return $self->legacy_ajax_file_response( %args, ajax_file => $ajax_path );
}

# status_response(%args)
# Executes the `/system/status` route.
# Input: normalized request arguments.
# Output: response array reference.
sub status_response {
    my ( $self, %args ) = @_;
    my $payload = $self->_page_status_payload;
    return [ 200, 'application/json; charset=utf-8', json_encode($payload) ];
}

# marked_js_response(%args)
# Serves the bundled jQuery asset used by saved bookmark pages.
# Input: normalized request arguments.
# Output: response array reference.
sub jquery_js_response {
    my ( $self, %args ) = @_;
    my $path = _bundled_public_asset_path( 'js', 'jquery-4.0.0.min.js' );
    open my $fh, '<:raw', $path or die "Unable to read $path: $!";
    local $/;
    my $content = <$fh>;
    close $fh or die "Unable to close $path: $!";
    return [ 200, 'application/javascript; charset=utf-8', $content ];
}

# _bundled_public_asset_path($type, $file)
# Resolves one bundled public asset from the repo share tree during
# development or from the installed distribution share dir after install.
# Input: asset type directory plus file name.
# Output: absolute asset path string.
sub _bundled_public_asset_path {
    my ( $type, $file ) = @_;
    die 'asset type is required' if !defined $type || $type eq '';
    die 'asset file is required' if !defined $file || $file eq '';

    my $module_source = $MODULE_SOURCE_PATH || File::Spec->rel2abs(__FILE__);
    my $module_dir    = dirname($module_source);
    my @candidates;

    for my $levels_up ( 4 .. 7 ) {
        push @candidates, File::Spec->catfile(
            File::Spec->rel2abs(
                File::Spec->catdir(
                    $module_dir,
                    ( File::Spec->updir ) x $levels_up,
                    'share',
                    'public',
                    $type,
                )
            ),
            $file,
        );
    }

    my $dist_root = eval { dist_dir('Developer-Dashboard') };
    if ( defined $dist_root && $dist_root ne '' ) {
        push @candidates, File::Spec->catfile( $dist_root, 'public', $type, $file );
    }

    my $module_path = $INC{'Developer/Dashboard/Web/App.pm'} || __FILE__;
    my $module_root = File::Spec->rel2abs(
        File::Spec->catdir(
            dirname($module_path),
            File::Spec->updir,
            File::Spec->updir,
            File::Spec->updir,
            File::Spec->updir,
        )
    );
    push @candidates, File::Spec->catfile( $module_root, 'auto', 'share', 'dist', 'Developer-Dashboard', 'public', $type, $file );
    push @candidates, File::Spec->catfile( $module_root, 'auto', 'Developer', 'Dashboard', 'public', $type, $file );

    my %seen;
    for my $candidate (@candidates) {
        next if !defined $candidate || $candidate eq '' || $seen{$candidate}++;
        return $candidate if -f $candidate;
    }

    die "Unable to find bundled public asset $type/$file";
}

# marked_js_response(%args)
# Serves the built-in marked shim asset.
# Input: normalized request arguments.
# Output: response array reference.
sub marked_js_response {
    my ( $self, %args ) = @_;
    return [ 200, 'application/javascript; charset=utf-8', "window.marked=window.marked||{parse:function(s){return s||'';}};\n" ];
}

# tiff_js_response(%args)
# Serves the built-in TIFF shim asset.
# Input: normalized request arguments.
# Output: response array reference.
sub tiff_js_response {
    my ( $self, %args ) = @_;
    return [ 200, 'application/javascript; charset=utf-8', "window.Tiff=window.Tiff||function(){};\n" ];
}

# loading_image_response(%args)
# Serves the built-in loading image response.
# Input: normalized request arguments.
# Output: response array reference.
sub loading_image_response {
    my ( $self, %args ) = @_;
    return [ 200, 'image/webp', '' ];
}

# static_file_response(%args)
# Serves one static asset from the dashboard public tree.
# Input: asset type and file name.
# Output: response array reference.
sub static_file_response {
    my ( $self, %args ) = @_;
    if ( ( $args{type} || '' ) eq 'js' ) {
        my $file = $args{file} || '';
        return $self->jquery_js_response(%args) if $file eq 'jquery.js' || $file eq 'jquery-4.0.0.min.js';
    }
    return $self->_serve_static_file( $args{type}, $args{file} );
}

# prefixed_static_file_response(%args)
# Resolves one `/js/...`, `/css/...`, or `/others/...` request by preferring the
# longest matching skill prefix and falling back to the normal nested public
# asset path when no skill-local asset exists.
# Input: asset type, requested file path, and normalized request metadata.
# Output: response array reference.
sub prefixed_static_file_response {
    my ( $self, %args ) = @_;
    my $type = $args{type} || '';
    my $file = $args{file} || '';
    my @segments = grep { defined && $_ ne '' } split m{/+}, $file;
    if ( my $spec = $self->_resolve_skill_route_spec(@segments) ) {
        my $skill_file = join '/', @{ $spec->{route_segments} || [] };
        if ( $skill_file ne '' ) {
            my $resolved = $self->_skill_static_file_path( $spec->{skill_name}, $type, $skill_file );
            my $route_spec = $self->_skill_route_spec( $type, $spec->{skill_name}, $skill_file );
            return $self->skill_static_file_response(
                %args,
                default_type => $route_spec ? ( $route_spec->{type} || '' ) : '',
                skill_name => $spec->{skill_name},
                file       => $skill_file,
            ) if $resolved ne '';
        }
    }
    return $self->static_file_response( %args, type => $type, file => $file );
}

# legacy_app_response(%args)
# Executes the saved `/app/<id>` render route and follows saved URL forwards.
# Input: saved app id plus normalized request query, body, headers, and remote address.
# Output: response array reference.
sub legacy_app_response {
    my ( $self, %args ) = @_;
    my ( $params, $body_params ) = $self->_request_params(%args);
    return $self->_legacy_app_response(
        id           => $args{id},
        query_params => $params,
        body_params  => $body_params,
        remote_addr  => $args{remote_addr},
        headers      => $args{headers} || {},
    );
}

# skill_route_response(%args)
# Executes routes provided by installed skills.
# Routes are namespaced under /skill/<repo-name>/<route>
# Input: skill_name, route, query params, headers, and remote address.
# Output: response array reference.
sub skill_route_response {
    my ( $self, %args ) = @_;
    my $skill_name = $args{skill_name} || '';
    my $route = $args{route} || '';
    
    return [ 400, 'text/plain; charset=utf-8', "Invalid skill name\n" ] if !$skill_name;
    return [ 400, 'text/plain; charset=utf-8', "Invalid skill route\n" ] if !$route;
    
    require Developer::Dashboard::SkillDispatcher;
    my $dispatcher = Developer::Dashboard::SkillDispatcher->new( paths => $self->{pages} ? $self->{pages}{paths} : undef );
    my ( $query_params, $body_params ) = $self->_request_params(%args);
    return $dispatcher->route_response(
        app          => $self,
        body_params  => $body_params,
        headers      => $args{headers} || {},
        path         => $args{path} || '',
        query_params => $query_params,
        remote_addr  => $args{remote_addr},
        route        => $route,
        skill_name   => $skill_name,
    );
}

# skill_static_file_response(%args)
# Serves one `/js/<skill>/...`, `/css/<skill>/...`, or `/others/<skill>/...` request from layered skill dashboards/public assets.
# Input: skill name, asset type, file path, and normalized request metadata.
# Output: response array reference.
sub skill_static_file_response {
    my ( $self, %args ) = @_;
    my $skill_name = $args{skill_name} || '';
    my $type       = $args{type}       || '';
    my $file       = $args{file}       || '';
    return [ 400, 'text/plain; charset=utf-8', "Invalid skill name\n" ] if $skill_name eq '';
    my $skill_file = $self->_skill_static_file_path( $skill_name, $type, $file );
    return $self->_serve_static_file_at_path( $type, $file, $skill_file, $args{default_type} || '' ) if $skill_file ne '';
    return $self->_serve_static_file( $type, join( '/', $skill_name, $file ) );
}

# transient_action_response(%args)
# Executes the transient `/action` route for encoded or token-backed actions.
# Input: normalized request path, query, body, headers, and remote address.
# Output: response array reference.
sub transient_action_response {
    my ( $self, %args ) = @_;
    my ( $params, $body_params ) = $self->_request_params(%args);
    my $headers = $args{headers} || {};
    my $path = $args{path} || '/action';
    if ( my $atoken = $params->{atoken} ) {
        return _transient_url_forbidden_response() if !_transient_url_tokens_allowed();
        return $self->_encoded_action_response(
            token  => $atoken,
            params => { %{$params}, %{$body_params} },
        );
    }

    my $token = exists $params->{token} ? $params->{token} : ( $body_params->{token} || '' );
    return _transient_url_forbidden_response() if $token ne '' && !_transient_url_tokens_allowed();
    my $id   = exists $params->{id} ? $params->{id} : ( $body_params->{id} || '' );
    my $page = $self->{pages}->load_transient_page($token);
    $page = $self->_page_with_runtime_state(
        $page,
        query_params => $params,
        body_params  => $body_params,
        path         => $path,
        remote_addr  => $args{remote_addr},
        headers      => $headers,
    );
    return $self->_action_response(
        id     => $id,
        page   => $page,
        source => 'transient',
        params => { %{$params}, %{$body_params} },
    );
}

# page_source_response(%args)
# Executes the saved-page source route.
# Input: saved page id plus normalized request query, body, headers, and remote address.
# Output: response array reference.
sub page_source_response {
    my ( $self, %args ) = @_;
    return _no_editor_response() if $self->_editor_disabled;
    my ( $params, $body_params ) = $self->_request_params(%args);
    my $page = $self->_load_named_page( $args{id} );
    $page->{meta}{raw_instruction} = $page->{meta}{raw_instruction} || $page->canonical_instruction;
    $page = $self->_page_with_runtime_state(
        $page,
        query_params => $params,
        body_params  => $body_params,
        path         => $args{path} || '/app/' . $args{id} . '/source',
        remote_addr  => $args{remote_addr},
        headers      => $args{headers} || {},
    );
    $page = $self->{runtime}->prepare_page(
        page            => $page,
        source          => $page->{meta}{source_kind} || 'saved',
        runtime_context => { params => { %{$params}, %{$body_params} } },
    );
    return [ 200, 'text/plain; charset=utf-8', $page->{meta}{raw_instruction} || $page->canonical_instruction ];
}

# page_edit_post_response(%args)
# Executes the saved-page editor POST route.
# Input: saved page id plus normalized request path, query, body, headers, and remote address.
# Output: response array reference.
sub page_edit_post_response {
    my ( $self, %args ) = @_;
    return _no_editor_response() if $self->_editor_disabled;
    my ( $params, $body_params ) = $self->_request_params(%args);
    my $instruction = exists $body_params->{instruction} ? $body_params->{instruction} : $params->{instruction};
    if ( defined $instruction ) {
        my $page = Developer::Dashboard::PageDocument->from_instruction($instruction);
        $page->{meta}{raw_instruction} = $instruction;
        $page->{id} ||= $args{id};
        $page->{meta}{source_kind} = 'saved';
        $self->{pages}->save_page($page);
        my $mode = $params->{mode} || $body_params->{mode} || 'edit';
        $page = $self->_page_with_runtime_state(
            $page,
            query_params => $params,
            body_params  => $body_params,
            path         => $args{path} || '/app/' . $args{id} . '/edit',
            remote_addr  => $args{remote_addr},
            headers      => $args{headers} || {},
        );
        $page = $self->{runtime}->prepare_page(
            page            => $page,
            source          => 'saved',
            runtime_context => { params => { %{$params}, %{$body_params} } },
        );
        return $self->_page_response( $page, $mode );
    }
    return $self->page_edit_response(%args);
}

# page_edit_response(%args)
# Executes the saved-page editor GET route.
# Input: saved page id plus normalized request query, body, headers, and remote address.
# Output: response array reference.
sub page_edit_response {
    my ( $self, %args ) = @_;
    return _no_editor_response() if $self->_editor_disabled;
    my ( $params, $body_params ) = $self->_request_params(%args);
    my $page = $self->_load_named_page( $args{id} );
    $page->{meta}{raw_instruction} = $page->{meta}{raw_instruction} || $page->canonical_instruction;
    $page = $self->_page_with_runtime_state(
        $page,
        query_params => $params,
        body_params  => $body_params,
        path         => $args{path} || '/app/' . $args{id} . '/edit',
        remote_addr  => $args{remote_addr},
        headers      => $args{headers} || {},
    );
    $page = $self->{runtime}->prepare_page(
        page            => $page,
        source          => $page->{meta}{source_kind} || 'saved',
        runtime_context => { params => { %{$params}, %{$body_params} } },
    );
    return [ 200, 'text/html; charset=utf-8', $self->_edit_html($page) ];
}

# page_action_response(%args)
# Executes the saved-page named action route.
# Input: saved page id, action id, and normalized request query, body, headers, and remote address.
# Output: response array reference.
sub page_action_response {
    my ( $self, %args ) = @_;
    my ( $params, $body_params ) = $self->_request_params(%args);
    my $page = $self->_load_named_page( $args{id} );
    $page = $self->_page_with_runtime_state(
        $page,
        query_params => $params,
        body_params  => $body_params,
        path         => $args{path} || '/app/' . $args{id} . '/action/' . $args{action_id},
        remote_addr  => $args{remote_addr},
        headers      => $args{headers} || {},
    );
    $page = $self->{runtime}->prepare_page(
        page            => $page,
        source          => $page->{meta}{source_kind} || 'saved',
        runtime_context => { params => { %{$params}, %{$body_params} } },
    );
    return $self->_action_response(
        id     => $args{action_id},
        page   => $page,
        source => $page->{meta}{source_kind} || 'saved',
        params => { %{$params}, %{$body_params} },
    );
}

# _blank_editor_response()
# Builds the default free-form bookmark editor response.
# Input: none.
# Output: response array reference.
sub _blank_editor_response {
    my ($self) = @_;
    return _no_editor_response() if $self->_editor_disabled;
    my $page = Developer::Dashboard::PageDocument->new(
        title       => 'Developer Dashboard',
        description => '',
        meta        => { source_kind => 'transient', source_format => 'legacy' },
    );
    my $request_ctx = $self->{_current_request_context} || {};
    $page->{meta}{request_context} = {
        host        => $request_ctx->{host} || '',
        path        => '/',
        remote_addr => $request_ctx->{remote_addr} || '',
        tier        => $request_ctx->{tier} || 'helper',
        username    => $request_ctx->{username} || '',
        role        => $request_ctx->{role} || '',
    };
    return [ 200, 'text/html; charset=utf-8', $self->_edit_html($page) ];
}

# _saved_page_exists($id)
# Checks whether one saved page id resolves in the effective bookmark lookup roots.
# Input: page id string.
# Output: boolean true when the saved page exists, otherwise false.
sub _saved_page_exists {
    my ( $self, $id ) = @_;
    return 0 if !defined $id || $id eq '';
    return eval { $self->{pages}->load_saved_page($id); 1 } ? 1 : 0;
}

# _page_response($page, $mode)
# Builds a page response for edit, render, or source mode.
# Input: page document object and mode string.
# Output: response array reference.
sub _page_response {
    my ( $self, $page, $mode ) = @_;
    my $source = $page->{meta}{raw_instruction} || $page->canonical_instruction;

    if ( $mode eq 'source' ) {
        return _no_editor_response() if $self->_editor_disabled;
        return [ 200, 'text/plain; charset=utf-8', $source ];
    }
    if ( $mode eq 'render' ) {
        return [ 200, 'text/html; charset=utf-8', $self->_render_page_html( $page, 'render' ) ];
    }

    return _no_editor_response() if $self->_editor_disabled;
    return [ 200, 'text/html; charset=utf-8', $self->_edit_html($page) ];
}

# _edit_html($page)
# Renders the browser edit/source view for a page.
# Input: page document object.
# Output: HTML string.
sub _edit_html {
    my ( $self, $page ) = @_;
    my $raw_source = $page->{meta}{raw_instruction} || $page->canonical_instruction;
    my $source = $raw_source;
    $source =~ s/&/&amp;/g;
    $source =~ s/</&lt;/g;
    $source =~ s/>/&gt;/g;

    my $page_id = $page->as_hash->{id} || '';
    my $is_saved = ( $page->{meta}{source_kind} || '' ) ne 'transient' && $page_id ne '';
    my $page_url = $is_saved ? $self->_saved_page_url($page_id) : '';
    my $urls = {
        edit   => $is_saved ? $page_url . '/edit' : $self->{pages}->editable_url($page),
        render => $is_saved ? $page_url : $self->{pages}->render_url($page),
        source => $is_saved ? $page_url . '/edit' : $self->{pages}->editable_url($page),
    };
    my $form_action = $is_saved ? $page_url . '/edit' : '/';

    my $title = $page->as_hash->{title};
    $title =~ s/&/&amp;/g;
    $title =~ s/</&lt;/g;
    $title =~ s/>/&gt;/g;

    my $html = <<'HTML';
<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>__TITLE__</title>
  <style>
    body { margin: 0; font-family: Georgia, serif; background: #f5efe2; color: #1f2a2e; }
    main { max-width: 980px; margin: 32px auto; background: #fffef9; border: 1px solid #ddd3c2; padding: 24px; }
    .editor-stack {
      position: relative;
      min-height: 520px;
      border: 1px solid #2a2f36;
      background: #1f2328;
      overflow: hidden;
    }
    .editor-overlay,
    .instruction-editor {
      display: block;
      width: 100%;
      min-height: 520px;
      box-sizing: border-box;
      margin: 0;
      padding: 12px;
      font-family: Menlo, Consolas, "Courier New", monospace;
      font-size: 14px;
      line-height: 21px;
      white-space: pre;
      word-break: normal;
      overflow-wrap: normal;
      overflow: auto;
      tab-size: 4;
      letter-spacing: 0;
    }
    .editor-overlay-viewport {
      position: absolute;
      inset: 0;
      overflow: hidden;
      pointer-events: none;
      background: transparent;
    }
    .editor-overlay {
      position: absolute;
      top: 0;
      left: 0;
      min-width: 100%;
      color: #e6edf3;
      background: transparent;
      unicode-bidi: plaintext;
      direction: ltr;
      will-change: transform;
    }
    .instruction-editor {
      position: relative;
      z-index: 1;
      height: 520px;
      color: transparent;
      border: 0;
      resize: vertical;
      background: transparent;
      caret-color: #f8f8f2;
      outline: none;
      -webkit-text-fill-color: transparent;
      unicode-bidi: plaintext;
      direction: ltr;
      overflow: auto;
      scrollbar-gutter: stable both-edges;
    }
    .instruction-editor::selection {
      background: rgba(121, 192, 255, 0.35);
      -webkit-text-fill-color: transparent;
    }
    .tok-directive { color: #ffd866; font-weight: normal; text-decoration: underline; text-decoration-thickness: 1px; text-underline-offset: 2px; }
    .tok-separator { color: #5c6370; }
    .tok-html { color: #78dce8; }
    .tok-tag { color: #ff7ab2; }
    .tok-attr { color: #ffcf6a; }
    .tok-value { color: #a9dc76; }
    .tok-css { color: #78dce8; }
    .tok-js { color: #ab9df2; }
    .tok-code { color: #a9dc76; }
    .tok-perl-keyword { color: #ff7ab2; }
    .tok-perl-var { color: #78dce8; }
    .tok-string { color: #ffd866; }
    .tok-comment { color: #727b84; }
    .tok-note { color: #ff6188; }
    a { color: #0b7a75; margin-right: 18px; text-decoration: none; }
  </style>
</head>
<body>
<main>
  __TOP_CHROME__
  <form method="post" action="__FORM_ACTION__" id="instruction-form">
    <div class="editor-stack">
      <div class="editor-overlay-viewport" aria-hidden="true"><pre class="editor-overlay" id="instruction-highlight">__INITIAL_HIGHLIGHT__</pre></div>
      <textarea class="instruction-editor" id="instruction-editor" name="instruction" wrap="off" spellcheck="false" autocapitalize="off" autocomplete="off" autocorrect="off">__SOURCE__</textarea>
    </div>
  </form>
</main>
<script>
const ddForm = document.getElementById('instruction-form');
const ddEditor = document.getElementById('instruction-editor');
const ddHighlight = document.getElementById('instruction-highlight');
function ddEscapeHtml(text) {
  return String(text)
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;');
}
function ddStoreToken(tokens, html) {
  tokens.push(html);
  return '\u001eHL' + (tokens.length - 1) + '\u001e';
}
function ddRestoreTokens(text, tokens) {
  if (!Array.isArray(tokens) || !tokens.length) return text;
  return String(text).replace(/\u001eHL(\d+)\u001e/g, function(_, index) {
    return Object.prototype.hasOwnProperty.call(tokens, index) ? tokens[index] : '';
  });
}
function ddHighlightInstruction(text) {
  const state = { section: '', htmlMode: '' };
  return String(text).split('\n').map((line) => ddHighlightLine(line, state)).join('\n');
}
function ddHighlightLine(line, state) {
  if (line === ':--------------------------------------------------------------------------------:') {
    return '<span class="tok-separator">' + ddEscapeHtml(line) + '</span>';
  }
  const match = line.match(/^([A-Za-z][A-Za-z0-9.]*:)(\s*)(.*)$/);
  if (match) {
    state.section = match[1].slice(0, -1).toUpperCase();
    state.htmlMode = '';
    return '<span class="tok-directive">' + ddEscapeHtml(match[1]) + '</span>' +
      ddEscapeHtml(match[2]) +
      ddHighlightSectionText(match[3], state);
  }
  return ddHighlightSectionText(line, state);
}
function ddHighlightSectionText(text, state) {
  const section = state.section || '';
  if (/^CODE\d+$/.test(section)) return ddHighlightPerlLine(text);
  if (section === 'HTML') return ddHighlightHtmlLine(text, state);
  if (section === 'STASH' || section === 'NOTE') return ddHighlightNoteLine(text);
  return ddEscapeHtml(text);
}
function ddHighlightNoteLine(text) {
  return ddEscapeHtml(text).replace(/(\[%[\s\S]*?%\])/g, '<span class="tok-note">$1</span>');
}
function ddHighlightHtmlLine(text, state) {
  let line = text;
  let output = '';
  while (line.length) {
    if (state.htmlMode === 'script') {
      const closeAt = line.search(/<\/script\s*>/i);
      if (closeAt >= 0) {
        output += ddHighlightJsText(line.slice(0, closeAt));
        const closeText = line.slice(closeAt).match(/^<\/script\s*>/i)[0];
        output += ddHighlightMarkupText(closeText);
        line = line.slice(closeAt + closeText.length);
        state.htmlMode = '';
        continue;
      }
      output += ddHighlightJsText(line);
      line = '';
      continue;
    }
    if (state.htmlMode === 'style') {
      const closeAt = line.search(/<\/style\s*>/i);
      if (closeAt >= 0) {
        output += ddHighlightCssText(line.slice(0, closeAt));
        const closeText = line.slice(closeAt).match(/^<\/style\s*>/i)[0];
        output += ddHighlightMarkupText(closeText);
        line = line.slice(closeAt + closeText.length);
        state.htmlMode = '';
        continue;
      }
      output += ddHighlightCssText(line);
      line = '';
      continue;
    }
    const openAt = line.search(/<(script|style)\b/i);
    if (openAt >= 0) {
      output += ddHighlightMarkupText(line.slice(0, openAt));
      const rest = line.slice(openAt);
      const tagMatch = rest.match(/^<(script|style)\b[\s\S]*?>/i);
      if (!tagMatch) {
        output += ddHighlightMarkupText(rest);
        line = '';
        continue;
      }
      const tagText = tagMatch[0];
      output += ddHighlightMarkupText(tagText);
      line = rest.slice(tagText.length);
      state.htmlMode = tagMatch[1].toLowerCase() === 'script' ? 'script' : 'style';
      continue;
    }
    output += ddHighlightMarkupText(line);
    line = '';
  }
  return output;
}
function ddHighlightMarkupText(text) {
  let html = ddEscapeHtml(text);
  html = html.replace(/(&lt;!--[\s\S]*?--&gt;)/g, '<span class="tok-comment">$1</span>');
  html = html.replace(/(\[%[\s\S]*?%\])/g, '<span class="tok-note">$1</span>');
  html = html.replace(/(&lt;\/?)([A-Za-z0-9:-]+)([^&]*?)(\/?&gt;)/g, function(_, open, tag, attrs, close) {
    let rendered = '<span class="tok-tag">' + open + tag + '</span>';
    rendered += attrs.replace(/([A-Za-z_:][-A-Za-z0-9_:.]*)(\s*=\s*)(&quot;.*?&quot;|'.*?'|[^\s>]+)/g, function(__, name, eq, value) {
      let valueClass = 'tok-value';
      if (name === 'style') valueClass += ' tok-css';
      if (name.startsWith('on')) valueClass += ' tok-js';
      return '<span class="tok-attr">' + name + '</span>' + eq + '<span class="' + valueClass + '">' + value + '</span>';
    });
    rendered += '<span class="tok-tag">' + close + '</span>';
    return rendered;
  });
  return html;
}
function ddHighlightCssText(text) {
  let css = ddEscapeHtml(text);
  const tokens = [];
  css = css.replace(/\/\*[\s\S]*?\*\//g, function(match) {
    return ddStoreToken(tokens, '<span class="tok-comment">' + match + '</span>');
  });
  css = css.replace(/([.#]?[A-Za-z_-][A-Za-z0-9_-]*)(\s*\{)/g, function(_, name, suffix) {
    return ddStoreToken(tokens, '<span class="tok-css">' + name + '</span>') + suffix;
  });
  css = css.replace(/([A-Za-z-]+)(\s*:)/g, function(_, name, suffix) {
    return ddStoreToken(tokens, '<span class="tok-attr">' + name + '</span>') + suffix;
  });
  css = css.replace(/(:\s*)([^;}{]+)/g, function(_, prefix, value) {
    return prefix + ddStoreToken(tokens, '<span class="tok-value tok-css">' + value + '</span>');
  });
  return ddRestoreTokens(css, tokens);
}
function ddHighlightJsText(text) {
  let js = ddEscapeHtml(text);
  const tokens = [];
  js = js.replace(/\/\/[^\n]*/g, function(match) {
    return ddStoreToken(tokens, '<span class="tok-comment">' + match + '</span>');
  });
  js = js.replace(/('(?:\\.|[^'])*'|&quot;(?:\\.|[^&])*?&quot;)/g, function(match) {
    return ddStoreToken(tokens, '<span class="tok-string">' + match + '</span>');
  });
  js = js.replace(/\b(const|let|var|function|return|if|else|for|while|class|new|await|async|try|catch|throw)\b/g, function(match) {
    return ddStoreToken(tokens, '<span class="tok-js">' + match + '</span>');
  });
  return ddRestoreTokens(js, tokens);
}
function ddHighlightPerlLine(text) {
  let perl = ddEscapeHtml(text);
  const tokens = [];
  perl = perl.replace(/(\[%[\s\S]*?%\])/g, function(match) {
    return ddStoreToken(tokens, '<span class="tok-note">' + match + '</span>');
  });
  perl = perl.replace(/(^|\s)(#.*)$/g, function(_, prefix, comment) {
    return prefix + ddStoreToken(tokens, '<span class="tok-comment">' + comment + '</span>');
  });
  perl = perl.replace(/('(?:\\.|[^'])*'|&quot;(?:\\.|[^&])*?&quot;)/g, function(match) {
    return ddStoreToken(tokens, '<span class="tok-string">' + match + '</span>');
  });
  perl = perl.replace(/\b(my|sub|return|if|elsif|else|for|foreach|while|last|next|die|print|use|local|our|state|undef)\b/g, function(match) {
    return ddStoreToken(tokens, '<span class="tok-perl-keyword">' + match + '</span>');
  });
  perl = perl.replace(/([$@%][A-Za-z_][A-Za-z0-9_]*)/g, function(match) {
    return ddStoreToken(tokens, '<span class="tok-perl-var">' + match + '</span>');
  });
  return ddRestoreTokens(perl, tokens);
}
function ddOverlayHtml(text) {
  const source = String(text);
  let html = ddHighlightInstruction(source);
  if (source.endsWith('\n')) html += ' ';
  return html;
}
function ddDirectiveName(line) {
  const match = String(line).match(/^([A-Za-z][A-Za-z0-9.]*)\s*:/);
  return match ? match[1].toUpperCase() : '';
}
function ddHighestCodeDirective(text) {
  let highest = 0;
  String(text).split('\n').forEach(function(line) {
    const match = String(line).match(/^(CODE)(\d+)\s*:/i);
    if (!match) return;
    const number = parseInt(match[2], 10);
    if (number > highest) highest = number;
  });
  return highest;
}
function ddNextDirectiveForContext(textBefore, fullText) {
  const directives = {};
  String(fullText).split('\n').forEach(function(line) {
    const name = ddDirectiveName(line);
    if (name) directives[name] = 1;
  });
  const priorLines = String(textBefore).split('\n');
  let priorDirective = '';
  for (let index = priorLines.length - 1; index >= 0; index -= 1) {
    const name = ddDirectiveName(priorLines[index]);
    if (!name) continue;
    priorDirective = name;
    break;
  }
  if (priorDirective === 'TITLE') {
    return directives.HTML ? '' : 'HTML: ';
  }
  if (priorDirective === 'HTML' || /^CODE\d+$/.test(priorDirective)) {
    return 'CODE' + (ddHighestCodeDirective(fullText) + 1) + ': ';
  }
  return '';
}
function ddApplyDirectiveAssist() {
  if (ddEditor.selectionStart !== ddEditor.selectionEnd) return false;
  const caret = ddEditor.selectionStart;
  const value = ddEditor.value;
  const lineStart = value.lastIndexOf('\n', Math.max(caret - 1, 0) - 1) + 1;
  const lineEndAt = value.indexOf('\n', caret);
  const lineEnd = lineEndAt >= 0 ? lineEndAt : value.length;
  const line = value.slice(lineStart, lineEnd);
  if (line !== ':---' || caret !== lineEnd) return false;

  const textBefore = value.slice(0, lineStart);
  const textAfter = value.slice(lineEnd);
  const nextDirective = ddNextDirectiveForContext(textBefore, value);
  let replacement = ':--------------------------------------------------------------------------------:';
  let nextCaret = lineStart + replacement.length;
  if (nextDirective) {
    replacement += '\n' + nextDirective;
    nextCaret += 1 + nextDirective.length;
  }
  ddEditor.value = textBefore + replacement + textAfter;
  ddEditor.setSelectionRange(nextCaret, nextCaret);
  return true;
}
function ddSyncEditorOverlay() {
  ddHighlight.style.minWidth = Math.max(ddEditor.scrollWidth, ddEditor.clientWidth) + 'px';
  ddHighlight.style.minHeight = Math.max(ddEditor.scrollHeight, ddEditor.clientHeight) + 'px';
  ddHighlight.style.transform = 'translate(' + (-ddEditor.scrollLeft) + 'px, ' + (-ddEditor.scrollTop) + 'px)';
}
function ddRenderEditor(text) {
  ddHighlight.innerHTML = ddOverlayHtml(text);
  ddSyncEditorOverlay();
}
ddEditor.addEventListener('input', function() {
  ddApplyDirectiveAssist();
  ddRenderEditor(ddEditor.value);
});
ddEditor.addEventListener('scroll', function() {
  ddSyncEditorOverlay();
});
ddEditor.addEventListener('change', function() {
  ddForm.submit();
});
window.addEventListener('resize', function() {
  ddSyncEditorOverlay();
});
ddEditor.value = __SOURCE_JSON__;
ddRenderEditor(ddEditor.value);
</script>
</body>
</html>
HTML

    $html =~ s/__TITLE__/$title/g;
    $html =~ s/__TOP_CHROME__/$self->_top_chrome_html( $page, \%$urls )/ge;
    $html =~ s/__INITIAL_HIGHLIGHT__/$self->_editor_overlay_html($raw_source)/ge;
    $html =~ s/__SOURCE__/$source/g;
    $html =~ s/__SOURCE_JSON__/_json_for_inline_script($raw_source)/ge;
    $html =~ s/__FORM_ACTION__/$form_action/g;
    return $html;
}

# _json_for_inline_script($text)
# Encodes text as JSON for inline script assignment without allowing HTML parser breakouts.
# Input: raw text string.
# Output: JSON string literal safe for inclusion inside a <script> block.
sub _json_for_inline_script {
    my ($text) = @_;
    my $json = json_encode( defined $text ? $text : '' );
    $json =~ s/</\\u003c/g;
    $json =~ s/>/\\u003e/g;
    $json =~ s/&/\\u0026/g;
    return $json;
}

# _highlight_instruction_html($source)
# Generates the initial syntax-coloured editor HTML from bookmark source text.
# Input: canonical bookmark instruction text.
# Output: highlighted HTML string for the editable source area.
sub _highlight_instruction_html {
    my ( $self, $source ) = @_;
    my %state = ( section => '', html_mode => '' );
    return join "\n", map { $self->_highlight_editor_line( $_, \%state ) } split /\n/, ( $source // '' ), -1;
}

# _editor_overlay_html($source)
# Generates the browser overlay HTML while preserving the textarea's final blank line geometry.
# Input: canonical bookmark instruction text.
# Output: highlighted HTML string with a trailing sentinel when the source ends in a newline.
sub _editor_overlay_html {
    my ( $self, $source ) = @_;
    my $html = $self->_highlight_instruction_html($source);
    $html .= ' ' if defined $source && $source =~ /\n\z/;
    return $html;
}

# _highlight_editor_line($line, $state)
# Highlights a single bookmark editor line while preserving exact layout.
# Input: raw line text and mutable parser state hash reference.
# Output: highlighted HTML fragment for that line.
sub _highlight_editor_line {
    my ( $self, $line, $state ) = @_;
    if ( $line eq ':--------------------------------------------------------------------------------:' ) {
        return qq{<span class="tok-separator">} . _escape_html($line) . qq{</span>};
    }
    if ( $line =~ /^([A-Za-z][A-Za-z0-9.]*:)(\s*)(.*)\z/ ) {
        my ( $directive, $space, $rest ) = ( $1, $2, $3 );
        $state->{section}   = uc substr( $directive, 0, -1 );
        $state->{html_mode} = '';
        return qq{<span class="tok-directive">} . _escape_html($directive) . qq{</span>}
          . _escape_html($space)
          . $self->_highlight_section_text( $rest, $state );
    }
    return $self->_highlight_section_text( $line, $state );
}

# _highlight_section_text($text, $state)
# Dispatches line highlighting based on the active bookmark section.
# Input: raw line text and mutable parser state hash reference.
# Output: highlighted HTML fragment.
sub _highlight_section_text {
    my ( $self, $text, $state ) = @_;
    my $section = $state->{section} || '';
    return $self->_highlight_perl_text($text) if $section =~ /^CODE\d+$/;
    return $self->_highlight_html_text( $text, $state ) if $section eq 'HTML';
    return $self->_highlight_note_text($text) if $section eq 'STASH' || $section eq 'NOTE';
    return _escape_html($text);
}

# _highlight_note_text($text)
# Highlights TT-style placeholders inside simple text sections.
# Input: raw section line text.
# Output: highlighted HTML fragment.
sub _highlight_note_text {
    my ( $self, $text ) = @_;
    my $html = _escape_html($text);
    $html =~ s{(\[%[\s\S]*?%\])}{<span class="tok-note">$1</span>}g;
    return $html;
}

# _highlight_html_text($text, $state)
# Highlights HTML section text with HTML, CSS, and JavaScript awareness.
# Input: raw section line text and mutable parser state hash reference.
# Output: highlighted HTML fragment.
sub _highlight_html_text {
    my ( $self, $text, $state ) = @_;
    my $line = $text;
    my $out  = '';
    while ( length $line ) {
        if ( ( $state->{html_mode} || '' ) eq 'script' ) {
            if ( $line =~ m{</script\s*>}i ) {
                my ( $before, $close, $after ) = $line =~ m{\A(.*?)(</script\s*>)(.*)\z}is;
                $out .= $self->_highlight_js_text($before);
                $out .= $self->_highlight_markup_text($close);
                $line = $after;
                $state->{html_mode} = '';
                next;
            }
            $out .= $self->_highlight_js_text($line);
            last;
        }
        if ( ( $state->{html_mode} || '' ) eq 'style' ) {
            if ( $line =~ m{</style\s*>}i ) {
                my ( $before, $close, $after ) = $line =~ m{\A(.*?)(</style\s*>)(.*)\z}is;
                $out .= $self->_highlight_css_text($before);
                $out .= $self->_highlight_markup_text($close);
                $line = $after;
                $state->{html_mode} = '';
                next;
            }
            $out .= $self->_highlight_css_text($line);
            last;
        }
        if ( $line =~ m{<(script|style)\b}i ) {
            my ( $before, $tag, $mode_name, $after ) = $line =~ m{\A(.*?)(<(script|style)\b[\s\S]*?>)(.*)\z}is;
            if ( defined $tag ) {
                my $mode = lc( $mode_name || '' ) eq 'script' ? 'script' : 'style';
                $out .= $self->_highlight_markup_text($before);
                $out .= $self->_highlight_markup_text($tag);
                $line = $after;
                $state->{html_mode} = $mode;
                next;
            }
        }
        $out .= $self->_highlight_markup_text($line);
        last;
    }
    return $out;
}

# _highlight_markup_text($text)
# Highlights markup fragments, tag names, attributes, and inline TT tokens.
# Input: raw HTML-ish text fragment.
# Output: highlighted HTML fragment.
sub _highlight_markup_text {
    my ( $self, $text ) = @_;
    my $html = _escape_html($text);
    $html =~ s{(&lt;!--[\s\S]*?--&gt;)}{<span class="tok-comment">$1</span>}g;
    $html =~ s{(\[%[\s\S]*?%\])}{<span class="tok-note">$1</span>}g;
    $html =~ s{(&lt;/?)([A-Za-z0-9:-]+)([^&]*?)(/?&gt;)}
              {$self->_highlight_tag_markup( $1, $2, $3, $4 )}ge;
    return $html;
}

# _highlight_tag_markup($open, $tag, $attrs, $close)
# Highlights a single escaped markup tag without changing surrounding layout.
# Input: escaped opening token, tag name, attribute text, and closing token.
# Output: highlighted HTML fragment.
sub _highlight_tag_markup {
    my ( $self, $open, $tag, $attrs, $close ) = @_;
    $attrs =~ s{([A-Za-z_:][-A-Za-z0-9_:.]*)(\s*=\s*)(&quot;.*?&quot;|'.*?'|[^\s>]+)}
             {my ( $name, $eq, $value ) = ( $1, $2, $3 );
              my $value_class = $name eq 'style' ? 'tok-value tok-css' : $name =~ /^on/ ? 'tok-value tok-js' : 'tok-value';
              qq{<span class="tok-attr">$name</span>$eq<span class="$value_class">$value</span>}}ge;
    return qq{<span class="tok-tag">$open$tag</span>$attrs<span class="tok-tag">$close</span>};
}

# _highlight_css_text($text)
# Highlights CSS-like text inside HTML style blocks or style attributes.
# Input: raw CSS text fragment.
# Output: highlighted HTML fragment.
sub _highlight_css_text {
    my ( $self, $text ) = @_;
    my $helper = ref($self) || __PACKAGE__;
    my $css = _escape_html($text);
    my @tokens;
    $css =~ s{(/\*[\s\S]*?\*/)}{$helper->_highlight_store_token( \@tokens, qq{<span class="tok-comment">$1</span>} )}ge;
    $css =~ s!([.\#]?[A-Za-z_-][A-Za-z0-9_-]*)(\s*\{)!$helper->_highlight_store_token( \@tokens, qq{<span class="tok-css">$1</span>} ) . $2!ge;
    $css =~ s!([A-Za-z-]+)(\s*:)!$helper->_highlight_store_token( \@tokens, qq{<span class="tok-attr">$1</span>} ) . $2!ge;
    $css =~ s!(:\s*)([^;\}\{]+)!$1 . $helper->_highlight_store_token( \@tokens, qq{<span class="tok-value tok-css">$2</span>} )!ge;
    return $helper->_highlight_restore_tokens( $css, \@tokens );
}

# _highlight_js_text($text)
# Highlights JavaScript-like text inside HTML script blocks.
# Input: raw JavaScript text fragment.
# Output: highlighted HTML fragment.
sub _highlight_js_text {
    my ( $self, $text ) = @_;
    my $helper = ref($self) || __PACKAGE__;
    my $js = _escape_html($text);
    my @tokens;
    $js =~ s{(//[^\n]*)}{$helper->_highlight_store_token( \@tokens, qq{<span class="tok-comment">$1</span>} )}ge;
    $js =~ s{('(?:\\.|[^'])*'|&quot;(?:\\.|[^&])*?&quot;)}{$helper->_highlight_store_token( \@tokens, qq{<span class="tok-string">$1</span>} )}ge;
    $js =~ s{\b(const|let|var|function|return|if|else|for|while|class|new|await|async|try|catch|throw)\b}{$helper->_highlight_store_token( \@tokens, qq{<span class="tok-js">$1</span>} )}ge;
    return $helper->_highlight_restore_tokens( $js, \@tokens );
}

# _highlight_perl_text($text)
# Highlights Perl-like text inside CODE sections.
# Input: raw Perl text fragment.
# Output: highlighted HTML fragment.
sub _highlight_perl_text {
    my ( $self, $text ) = @_;
    my $helper = ref($self) || __PACKAGE__;
    my $perl = _escape_html($text);
    my @tokens;
    $perl =~ s{(\[%[\s\S]*?%\])}{$helper->_highlight_store_token( \@tokens, qq{<span class="tok-note">$1</span>} )}ge;
    my $comment = '';
    if ( $perl =~ /\A(.*?)(\s\#.*|\#.*)\z/ ) {
        $perl = $1;
        $comment = $helper->_highlight_store_token( \@tokens, qq{<span class="tok-comment">$2</span>} );
    }
    $perl =~ s{('(?:\\.|[^'])*'|&quot;(?:\\.|[^&])*?&quot;)}{$helper->_highlight_store_token( \@tokens, qq{<span class="tok-string">$1</span>} )}ge;
    $perl =~ s{\b(my|sub|return|if|elsif|else|for|foreach|while|last|next|die|print|use|local|our|state|undef)\b}{$helper->_highlight_store_token( \@tokens, qq{<span class="tok-perl-keyword">$1</span>} )}ge;
    $perl =~ s{([$@%][A-Za-z_][A-Za-z0-9_]*)}{$helper->_highlight_store_token( \@tokens, qq{<span class="tok-perl-var">$1</span>} )}ge;
    return $helper->_highlight_restore_tokens( $perl . $comment, \@tokens );
}

# _highlight_store_token($tokens, $html)
# Replaces one rendered highlight fragment with a placeholder so later regex passes do not mutate inserted markup.
# Input: token array reference and rendered HTML fragment string.
# Output: placeholder marker string.
sub _highlight_store_token {
    my ( $self, $tokens, $html ) = @_;
    push @{$tokens}, $html;
    return sprintf "\x1EHL%d\x1E", scalar( @{$tokens} ) - 1;
}

# _highlight_restore_tokens($text, $tokens)
# Restores placeholder-protected highlight fragments after all regex passes finish.
# Input: placeholder-bearing text string and token array reference.
# Output: highlighted HTML string.
sub _highlight_restore_tokens {
    my ( $self, $text, $tokens ) = @_;
    return $text if ref($tokens) ne 'ARRAY' || !@{$tokens};
    $text =~ s/\x1EHL(\d+)\x1E/( defined $tokens->[$1] ? $tokens->[$1] : '' )/ge;
    return $text;
}

# _escape_html($text)
# Escapes plain text for safe HTML output in the bookmark editor.
# Input: raw text.
# Output: escaped HTML-safe text.
sub _escape_html {
    my ($text) = @_;
    $text = '' if !defined $text;
    $text =~ s/&/&amp;/g;
    $text =~ s/</&lt;/g;
    $text =~ s/>/&gt;/g;
    return $text;
}

# _render_page_html($page, $mode)
# Renders the browser page view and action URLs for a page.
# Input: page document object and mode string.
# Output: HTML string.
sub _render_page_html {
    my ( $self, $page, $mode ) = @_;
    $page->with_mode( $mode || 'render' );
    my $request_context = $page->{meta}{request_context} || {};
    my $current_page = $self->_effective_current_page($page);
    my $transient_allowed = _transient_url_tokens_allowed();
    my %action_urls;
    for my $action ( @{ $page->as_hash->{actions} || [] } ) {
        next if ref($action) ne 'HASH' || !$action->{id};
        my $saved_action_url = $self->_saved_page_url( $page->as_hash->{id} || '' );
        $saved_action_url .= '/action/' . $action->{id} if $saved_action_url ne '';
        my $atoken = $self->{actions}
          ? $self->{actions}->encode_action_payload(
              action => $action,
              page   => $page,
              source => $page->{meta}{source_kind} || 'saved',
            )
          : undef;
        $action_urls{ $action->{id} } = $atoken
          ? ( $transient_allowed ? '/action?atoken=' . URI::Escape::uri_escape($atoken) : $saved_action_url )
          : $saved_action_url;
    }
    my $page_url = ( $page->{meta}{source_kind} || '' ) eq 'transient'
      ? $self->{pages}->editable_url($page)
      : $self->_saved_page_url( $page->as_hash->{id} || '' );
    my $runtime_context = {
        params       => { %{ $page->{state} || {} } },
        current_page => $current_page,
    };
    return $page->render_html(
        action_urls => \%action_urls,
        page_url    => $page_url,
        chrome_html => $self->_top_chrome_html(
            $page,
            {
                edit   => ( ( $page->{meta}{source_kind} || '' ) eq 'transient' && $transient_allowed ) ? '/?token=' . $self->{pages}->encode_page($page) : $page_url . '/edit',
                render => ( ( $page->{meta}{source_kind} || '' ) eq 'transient' && $transient_allowed ) ? '/?mode=render&token=' . $self->{pages}->encode_page($page) : $page_url,
                source => ( ( $page->{meta}{source_kind} || '' ) eq 'transient' && $transient_allowed ) ? '/?token=' . $self->{pages}->encode_page($page) : $page_url . '/edit',
            },
        ),
        nav_html => $self->_nav_items_html(
            page            => $page,
            runtime_context => $runtime_context,
        ),
    );
}

# _effective_current_page($page)
# Resolves the logical page path used by nav fragments and runtime template context.
# Input: page document object.
# Output: request path string, preferring the saved bookmark route for transient play of named bookmarks.
sub _effective_current_page {
    my ( $self, $page ) = @_;
    my $request_context = $page->{meta}{request_context} || {};
    my $request_path = $request_context->{path} || '';
    my $page_id = $page->as_hash->{id} || '';

    return $self->_saved_page_url($page_id)
      if $request_path eq '/'
      && $page_id ne ''
      && ( $page->{meta}{source_kind} || '' ) eq 'transient';

    return $request_path;
}

# _nav_items_html(%args)
# Renders saved nav/*.tt bookmark fragments between page chrome and body.
# Input: page document object and runtime context hash reference.
# Output: HTML fragment string.
sub _nav_items_html {
    my ( $self, %args ) = @_;
    my $page = $args{page} || return '';
    my $page_id = $page->as_hash->{id} || '';
    return '' if $page_id =~ m{\Anav/};
    return '' if ( $page->{meta}{skill_route_id} || '' ) =~ m{\Anav/};

    my $paths = $self->{pages}{paths} || return '';
    my @roots = $paths->can('dashboards_layers') ? $paths->dashboards_layers : $paths->dashboards_roots;
    my %nav_ids;
    my @nav_ids;
    for my $dashboards_root (@roots) {
        my $nav_root = File::Spec->catdir( $dashboards_root, 'nav' );
        next if !-d $nav_root;
        opendir my $dh, $nav_root or next;
        for my $entry ( sort grep {
            $_ ne '.' && $_ ne '..'
              && $_ =~ /\.tt\z/
              && -f File::Spec->catfile( $nav_root, $_ )
        } readdir $dh )
        {
            my $nav_id = 'nav/' . $entry;
            push @nav_ids, $nav_id if !$nav_ids{$nav_id}++;
        }
        closedir $dh;
    }
    my @items;
    my $current_page = $args{runtime_context}{current_page} || '';
    for my $nav_id (@nav_ids) {
        my $nav_page = eval { $self->_load_named_page($nav_id) };
        next if !$nav_page || $@;
        $nav_page->{meta}{raw_instruction} = $nav_page->canonical_instruction;
        $nav_page = $self->{runtime}->prepare_page(
            page            => $self->_page_with_runtime_state(
                $nav_page,
                query_params => $args{runtime_context}{params} || {},
                body_params  => {},
                path         => $self->_saved_page_url($page_id),
                remote_addr  => $self->{_current_request_context}{remote_addr},
                headers      => { host => $self->{_current_request_context}{host} || '' },
            ),
            source          => $nav_page->{meta}{source_kind} || 'saved',
            runtime_context => {
                %{ $args{runtime_context} || { params => {} } },
                current_page => $current_page,
            },
        );
        my $fragment = $self->_page_fragment_html($nav_page);
        next if $fragment eq '';
        push @items, qq{<li data-nav-id="} . _escape_html($nav_id) . qq{">$fragment</li>};
    }

    require Developer::Dashboard::SkillDispatcher;
    my $dispatcher = Developer::Dashboard::SkillDispatcher->new( paths => $paths );
    for my $nav_page ( @{ $dispatcher->all_skill_nav_pages || [] } ) {
        $nav_page = $self->{runtime}->prepare_page(
            page            => $self->_page_with_runtime_state(
                $nav_page,
                query_params => $args{runtime_context}{params} || {},
                body_params  => {},
                path         => $self->_saved_page_url($page_id),
                remote_addr  => $self->{_current_request_context}{remote_addr},
                headers      => { host => $self->{_current_request_context}{host} || '' },
            ),
            source          => 'skill',
            runtime_context => {
                %{ $args{runtime_context} || { params => {} } },
                current_page => $current_page,
            },
        );
        my $nav_id = $nav_page->as_hash->{id} || '';
        my $fragment = $self->_page_fragment_html($nav_page);
        next if $fragment eq '';
        push @items, qq{<li data-nav-id="} . _escape_html($nav_id) . qq{">$fragment</li>};
    }

    return '' if !@items;
    return qq{<section class="dashboard-nav-items"><ul>} . join( '', @items ) . qq{</ul></section>};
}

# _page_fragment_html($page)
# Builds a body-level HTML fragment from a prepared page document.
# Input: prepared page document object.
# Output: HTML fragment string.
sub _page_fragment_html {
    my ( $self, $page ) = @_;
    return '' if !$page;

    my $fragment = defined $page->{layout}{body} ? $page->{layout}{body} : '';

    for my $chunk ( @{ $page->{meta}{runtime_outputs} || [] } ) {
        next if !defined $chunk || ref($chunk);
        $fragment .= $chunk;
    }
    for my $chunk ( @{ $page->{meta}{runtime_errors} || [] } ) {
        next if !defined $chunk || ref($chunk);
        $fragment .= qq{<pre class="runtime-error">} . _escape_html($chunk) . qq{</pre>\n};
    }

    return $fragment;
}

# _action_response(%args)
# Executes a named page action and converts the result into an HTTP response.
# Input: page object, action id, source classification, and params.
# Output: response array reference.
sub _action_response {
    my ( $self, %args ) = @_;
    return [ 501, 'text/plain; charset=utf-8', "Action runner is not configured\n" ] if !$self->{actions};
    my $page = $args{page};
    my $id   = $args{id} || '';
    my ($action) = grep { ref($_) eq 'HASH' && ( $_->{id} || '' ) eq $id } @{ $page->as_hash->{actions} || [] };
    return [ 404, 'text/plain; charset=utf-8', "Action not found\n" ] if !$action;

    my $result = eval {
        $self->{actions}->run_page_action(
            action => $action,
            page   => $page,
            params => $args{params} || {},
            source => $args{source} || 'saved',
        );
    };
    if ($@) {
        return [ 403, 'text/plain; charset=utf-8', "$@" ];
    }
    if ( ref($result) eq 'HASH' && exists $result->{body} ) {
        return [ 200, $result->{content_type} || 'text/plain; charset=utf-8', $result->{body} ];
    }
    return [ 200, 'application/json; charset=utf-8', json_encode($result) ];
}

# _encoded_action_response(%args)
# Executes an encoded action payload and converts it into an HTTP response.
# Input: encoded action token and params hash reference.
# Output: response array reference.
sub _encoded_action_response {
    my ( $self, %args ) = @_;
    return [ 501, 'text/plain; charset=utf-8', "Action runner is not configured\n" ] if !$self->{actions};
    my $result = eval {
        $self->{actions}->run_encoded_action(
            token  => $args{token},
            params => $args{params} || {},
        );
    };
    if ($@) {
        return [ 403, 'text/plain; charset=utf-8', "$@" ];
    }
    if ( ref($result) eq 'HASH' && exists $result->{body} ) {
        return [ 200, $result->{content_type} || 'text/plain; charset=utf-8', $result->{body} ];
    }
    return [ 200, 'application/json; charset=utf-8', json_encode($result) ];
}

# _load_named_page($id)
# Loads a page using the resolver when present or the page store otherwise.
# Input: page id string.
# Output: page document object.
sub _load_named_page {
    my ( $self, $id ) = @_;
    return $self->{resolver}->load_named_page($id) if $self->{resolver};
    return $self->{pages}->load_saved_page($id);
}

# _legacy_app_response(%args)
# Loads an older /app/<name> resource as either a bookmark page or saved URL forward.
# Input: bookmark id and request metadata.
# Output: response array reference.
sub _legacy_app_response {
    my ( $self, %args ) = @_;
    my $id = $args{id} || die 'Missing app id';
    my $parsed = eval { $self->_load_named_page($id) };
    if ($parsed) {
        $parsed = $self->_page_with_runtime_state(
            $parsed,
            query_params => $args{query_params} || {},
            body_params  => $args{body_params}  || {},
            path         => '/app/' . $id,
            remote_addr  => $args{remote_addr},
            headers      => $args{headers},
        );
        $parsed = $self->{runtime}->prepare_page(
            page            => $parsed,
            source          => 'saved',
            runtime_context => { params => {} },
        );
        return [ 200, 'text/html; charset=utf-8', $self->_render_page_html( $parsed, 'render' ) ];
    }

    my $raw = eval { $self->{pages}->read_saved_entry($id) };
    if ( !defined $raw || $@ ) {
        my $skill_response = $self->_skill_app_fallback_response( id => $id, %args );
        return $skill_response if $skill_response;
        return $self->_missing_named_page_response($id);
    }
    my $target = _trim($raw);
    my $uri = URI->new($target);
    my $path = $uri->path;
    my %bookmark_params = _parse_query( scalar( $uri->query // '' ) );
    my %forward_params = (
        %bookmark_params,
        %{ $args{query_params} || {} },
        %{ $args{body_params}  || {} },
    );
    my $query = _build_query( \%forward_params );
    return $self->dispatch_request(
        path        => $path,
        query       => $query,
        method      => 'GET',
        body        => '',
        remote_addr => $args{remote_addr},
        headers     => $args{headers} || {},
    );
}

# _skill_app_fallback_response(%args)
# Attempts the /app/<skill> or /app/<skill>/<page> fallback before the blank saved-page editor opens.
# Input: unresolved app id plus normalized request metadata.
# Output: response array reference for a matched skill route, a 404 for missing namespaced skill routes, or undef when the id is not a skill route.
sub _skill_app_fallback_response {
    my ( $self, %args ) = @_;
    my $id = $args{id} || return;
    my @segments = grep { defined && $_ ne '' } split m{/+}, $id;
    return if !@segments;

    require Developer::Dashboard::SkillDispatcher;
    require Developer::Dashboard::SkillManager;
    my $dispatcher = Developer::Dashboard::SkillDispatcher->new( paths => $self->{pages} ? $self->{pages}{paths} : undef );
    my $manager = Developer::Dashboard::SkillManager->new( paths => $self->{pages} ? $self->{pages}{paths} : undef );
    my $installed_skill = $manager->get_skill_path( $segments[0], include_disabled => 1 );
    return $segments[1] ? [ 404, 'text/plain; charset=utf-8', "Not found\n" ] : undef if !$installed_skill;
    my $spec = $dispatcher->resolve_route_segments( \@segments, include_disabled => 1 );
    return $segments[1] ? [ 404, 'text/plain; charset=utf-8', "Not found\n" ] : undef if !$spec;
    if ( !@{ $spec->{skill_layers} || [] } ) {
        return [ 404, 'text/plain; charset=utf-8', "Not found\n" ];
    }

    return $dispatcher->route_response(
        app          => $self,
        skill_name   => $spec->{skill_name},
        route        => join( '/', @{ $spec->{route_segments} || [] } ),
        query_params => $args{query_params} || {},
        body_params  => $args{body_params}  || {},
        remote_addr  => $args{remote_addr},
        headers      => $args{headers} || {},
        path         => '/app/' . $id,
    );
}

# _resolve_skill_route_spec(@segments)
# Resolves the longest installed skill-prefix for a slash-delimited route tail.
# Input: one list of path segments.
# Output: hash reference with skill_name, route_segments, and skill_layers, or undef.
sub _resolve_skill_route_spec {
    my ( $self, @segments ) = @_;
    return $self->_skill_dispatcher->resolve_route_segments( \@segments );
}

# _custom_skill_route_response(%args)
# Resolves one non-smart custom skill route path after all primary routes have
# already declined to handle the request.
# Input: normalized request metadata plus route_path.
# Output: response array reference or undef when no custom skill route matches.
sub _custom_skill_route_response {
    my ( $self, %args ) = @_;
    my $route_path = $args{route_path} || '';
    return if $route_path eq '' || $route_path eq '/';
    my $spec = $self->_skill_dispatcher->resolve_custom_route_path($route_path);
    return if !$spec;
    if ( !( $spec->{skill_name} || '' ) ) {
        my $kind = $spec->{kind} || '';
        my $dispatch_path = '';
        if ( $kind eq 'app' ) {
            $dispatch_path = '/app/' . ( $spec->{route_id} || '' );
        }
        elsif ( $kind eq 'ajax' ) {
            $dispatch_path = '/ajax/' . ( $spec->{ajax_file} || '' );
        }
        elsif ( grep { $_ eq $kind } qw(js css others) ) {
            $dispatch_path = '/' . $kind . '/' . ( $spec->{file} || '' );
        }
        return if $dispatch_path eq '';
        return $self->dispatch_request(
            %args,
            path => $dispatch_path,
        );
    }
    if ( ( $spec->{kind} || '' ) eq 'app' ) {
        return $self->skill_route_response(
            %args,
            path       => $route_path,
            route      => $spec->{route_id},
            skill_name => $spec->{skill_name},
        );
    }
    if ( ( $spec->{kind} || '' ) eq 'ajax' ) {
        return $self->skill_ajax_file_response(
            %args,
            ajax_file    => $spec->{ajax_file},
            default_type => $spec->{type} || '',
            skill_name   => $spec->{skill_name},
        );
    }
    if ( grep { $_ eq ( $spec->{kind} || '' ) } qw(js css others) ) {
        return $self->skill_static_file_response(
            %args,
            default_type => $spec->{type} || '',
            file         => $spec->{file},
            skill_name   => $spec->{skill_name},
            type         => $spec->{kind},
        );
    }
    return;
}

# _skill_dispatcher()
# Builds the skill dispatcher used by web route helpers that resolve layered
# skill routes, ajax handlers, and static assets.
# Input: none.
# Output: Developer::Dashboard::SkillDispatcher object.
sub _skill_dispatcher {
    my ($self) = @_;
    require Developer::Dashboard::SkillDispatcher;
    return Developer::Dashboard::SkillDispatcher->new( paths => $self->{pages} ? $self->{pages}{paths} : undef );
}

# _skill_route_spec($kind, $skill_name, $target)
# Resolves one custom skill route specification for default mime handling.
# Input: route kind string, skill name string, and relative route target.
# Output: route spec hash reference or undef.
sub _skill_route_spec {
    my ( $self, $kind, $skill_name, $target ) = @_;
    return if !$kind || !$skill_name || !$target;
    return $self->_skill_dispatcher->skill_route_spec( $kind, $skill_name, $target );
}

# _skill_ajax_route_spec($skill_name, $ajax_file)
# Resolves one custom skill ajax route specification for default mime handling.
# Input: skill name string and relative ajax file name.
# Output: route spec hash reference or undef.
sub _skill_ajax_route_spec {
    my ( $self, $skill_name, $ajax_file ) = @_;
    return $self->_skill_route_spec( 'ajax', $skill_name, $ajax_file );
}

# _missing_named_page_response($id)
# Builds the editor response for an unknown /app/<id> route using a blank bookmark template.
# Input: requested app id string without the /app/ prefix.
# Output: response array reference containing the edit view.
sub _missing_named_page_response {
    my ( $self, $id ) = @_;
    return [ 404, 'text/plain; charset=utf-8', "Not found\n" ] if $self->_editor_disabled;
    my $bookmark_path = $self->_saved_page_url($id);
    my $instruction = join "\n",
        'TITLE: Developer Dashboard',
        ':--------------------------------------------------------------------------------:',
        "BOOKMARK: $bookmark_path",
        ':--------------------------------------------------------------------------------:',
        'HTML:',
        '',
        'Blank page',
        '';
    my $page = Developer::Dashboard::PageDocument->from_instruction($instruction);
    $page->{meta}{raw_instruction} = $instruction;
    $page->{meta}{source_kind} = 'saved';
    return [ 200, 'text/html; charset=utf-8', $self->_edit_html($page) ];
}

# _build_query($params)
# Builds a URL query string from a flat hash reference.
# Input: flat hash reference of request parameters.
# Output: URL-encoded query string.
sub _build_query {
    my ($params) = @_;
    return '' if ref($params) ne 'HASH' || !%{$params};
    return join '&',
      map {
          URI::Escape::uri_escape($_) . '=' . URI::Escape::uri_escape( defined $params->{$_} ? $params->{$_} : '' )
      } sort keys %{$params};
}

# _legacy_ajax_response(%args)
# Decodes and executes an older /ajax token payload.
# Input: request params and metadata.
# Output: response array reference.
sub _legacy_ajax_response {
    my ( $self, %args ) = @_;
    my $params = $args{params} || {};
    my $type  = $params->{type} || 'text';
    my $code;
    my $saved_path = '';
    if ( my $token = $params->{token} ) {
        $code = eval { decode_payload($token) };
        return [ 400, 'text/plain; charset=utf-8', "$@" ] if $@;
    }
    elsif ( ( $args{saved_ajax_path} || '' ) ne '' ) {
        $saved_path = $args{saved_ajax_path};
        return [ 404, 'text/plain; charset=utf-8', "Ajax handler not found\n" ] if !-f $saved_path;
    }
    elsif ( ( $params->{file} || '' ) ne '' ) {
        my $runtime_root = $self->{pages}{paths} ? $self->{pages}{paths}->runtime_root : '';
        $saved_path = eval {
            Developer::Dashboard::Zipper::saved_ajax_file_path(
                file         => $params->{file},
                runtime_root => $runtime_root,
            );
        };
        return [ 400, 'text/plain; charset=utf-8', "$@" ] if $@;
        return [ 404, 'text/plain; charset=utf-8', "Ajax handler not found\n" ] if $saved_path eq '' || !-f $saved_path;
    }
    else {
        return [ 400, 'text/plain; charset=utf-8', "missing token\n" ];
    }
    my $page = Developer::Dashboard::PageDocument->new(
        id    => ( $params->{page} || $params->{file} || '' ),
        title => 'Bookmark Ajax',
    );
    return [
        200,
        _ajax_content_type($type),
        {
            stream => sub {
                my ($writer) = @_;
                if ($saved_path ne '') {
                    $self->{runtime}->stream_saved_ajax_file(
                        path          => $saved_path,
                        page          => $params->{page} || '',
                        type          => $type,
                        params        => $params,
                        stdout_writer => $writer,
                        stderr_writer => $writer,
                    );
                    return;
                }
                my $result = $self->{runtime}->stream_code_block(
                    code            => $code,
                    page            => $page,
                    source          => 'transient',
                    state           => {},
                    runtime_context => { params => $params },
                    stdout_writer   => $writer,
                    stderr_writer   => $writer,
                    return_writer   => $writer,
                );
                $writer->( $result->{error} ) if defined $result->{error} && $result->{error} ne '';
            },
        },
    ];
}

# _ajax_content_type($type)
# Maps symbolic Ajax response types to concrete content types while accepting
# raw mime strings from skill route metadata.
# Input: symbolic type string or raw mime type string.
# Output: HTTP content type string.
sub _ajax_content_type {
    my ($type) = @_;
    my %types = (
        html => 'text/html; charset=utf-8',
        text => 'text/plain; charset=utf-8',
        json => 'application/json; charset=utf-8',
        js   => 'application/javascript; charset=utf-8',
        xml  => 'application/xml; charset=utf-8',
        yml  => 'text/plain; charset=utf-8',
        yaml => 'text/plain; charset=utf-8',
        xslt => 'application/xml; charset=utf-8',
    );
    my $value = defined $type && $type ne '' ? $type : 'text';
    return $types{$value} if exists $types{$value};
    return $value if $value =~ m{/};
    return 'text/plain; charset=utf-8';
}

# _legacy_ajax_allowed($params)
# Checks whether an older /ajax request is allowed under the transient token policy.
# Input: flat request parameter hash reference.
# Output: boolean true when no token is present or transient token URLs are enabled.
sub _legacy_ajax_allowed {
    my ( $self, $params ) = @_;
    return 1 if ref($params) ne 'HASH';
    return 1 if ( $params->{file} || '' ) ne '';
    return 1 if ( $params->{token} || '' ) eq '';
    return _transient_url_tokens_allowed();
}

# ajax_singleton_stop_response(%args)
# Terminates one saved-Ajax singleton worker on explicit browser page-lifecycle cleanup.
# Input: normalized request params containing one singleton name.
# Output: response array reference with a no-content status.
sub ajax_singleton_stop_response {
    my ( $self, %args ) = @_;
    my ($params) = $self->_request_params(%args);
    my $singleton = $self->{runtime}->_normalize_saved_ajax_singleton( $params->{singleton} );
    $self->{runtime}->_kill_saved_ajax_singleton($singleton) if $singleton ne '';
    return [ 204, 'text/plain; charset=utf-8', '' ];
}

# _parse_query($query)
# Parses a URL-encoded query/body string into a flat parameter hash.
# Input: query string.
# Output: key/value hash.
sub _parse_query {
    my ($query) = @_;
    return () if !defined $query || $query eq '';
    my %params;
    for my $pair ( split /&/, $query ) {
        my ( $k, $v ) = split /=/, $pair, 2;
        next if !defined $k || $k eq '';
        $k =~ tr/+/ /;
        my $name = uri_unescape($k);
        if ( defined $v && $name ne 'token' && $name ne 'atoken' ) {
            $v =~ tr/+/ /;
        }
        $params{$name} = defined $v ? uri_unescape($v) : '';
    }
    return %params;
}

# _request_params(%args)
# Parses the normalized raw query/body strings for one request.
# Input: request method, raw query string, and raw body string.
# Output: query-parameter and body-parameter hash references.
sub _request_params {
    my ( $self, %args ) = @_;
    my $query = defined $args{query} ? $args{query} : '';
    my $body  = defined $args{body}  ? $args{body}  : '';
    my $method = uc( $args{method} || 'GET' );
    my %params = _parse_query($query);
    my %body_params = $method eq 'POST' ? _parse_query($body) : ();
    return ( \%params, \%body_params );
}

# _page_with_runtime_state($page, %args)
# Merges request-time state into a page document before rendering or actions.
# Input: page document object plus query/body/context metadata.
# Output: updated page document object.
sub _page_with_runtime_state {
    my ( $self, $page, %args ) = @_;
    my %params = (
        %{ $args{query_params} || {} },
        %{ $args{body_params}  || {} },
    );
    my $request_ctx = $self->{_current_request_context} || {};
    delete @params{ grep { exists $params{$_} } qw(token atoken id mode instruction) };
    $page->merge_state(\%params) if %params;
    $page->{meta}{request_context} = {
        host        => $args{headers}{host} || $request_ctx->{host} || '',
        path        => $args{path} || '',
        remote_addr => $args{remote_addr} || $request_ctx->{remote_addr} || '',
        tier        => $request_ctx->{tier} || 'helper',
        username    => $request_ctx->{username} || '',
        role        => $request_ctx->{role} || '',
    };
    return $page;
}

# _trim($text)
# Trims leading and trailing whitespace from text.
# Input: text string.
# Output: trimmed string.
sub _trim {
    my ($text) = @_;
    $text = '' if !defined $text;
    $text =~ s/\A\s+//;
    $text =~ s/\s+\z//;
    return $text;
}

# _login_redirect_target(%args)
# Builds the original request path/query that helper login should return to.
# Input: normalized request path and query values.
# Output: sanitized relative redirect target string, defaulting to '/'.
sub _login_redirect_target {
    my ( $self, %args ) = @_;
    my $path = defined $args{path} && $args{path} ne '' ? $args{path} : '/';
    my $query = defined $args{query} && $args{query} ne '' ? '?' . $args{query} : '';
    return $self->_sanitize_redirect_target( $path . $query );
}

# _sanitize_redirect_target($target)
# Validates a post-login redirect target so helpers only return to local app routes.
# Input: requested redirect target string.
# Output: safe relative redirect target string, or '/' when invalid.
sub _sanitize_redirect_target {
    my ( $self, $target ) = @_;
    $target = _trim($target);
    return '/' if $target eq '';
    return '/' if $target !~ m{\A/};
    return '/' if $target =~ m{\A//};
    return '/' if $target =~ m{[\r\n]};
    return '/' if $target =~ m{\A/login(?:\z|[/?#])};
    return $target;
}

# _normalized_saved_page_id($id)
# Normalizes one saved bookmark id for route generation.
# Input: saved bookmark id string, optionally already prefixed with /app/.
# Output: bookmark id string without leading /app/ or duplicate slashes.
sub _normalized_saved_page_id {
    my ( $self, $id ) = @_;
    $id = _trim($id);
    $id =~ s{\A/+app/+}{};
    $id =~ s{\A/+}{};
    return $id;
}

# _saved_page_url($id)
# Builds the canonical /app/<id> route for one saved bookmark id.
# Input: saved bookmark id string.
# Output: canonical /app/<id> path string, or empty string when id is empty.
sub _saved_page_url {
    my ( $self, $id ) = @_;
    my $normalized = $self->_normalized_saved_page_id($id);
    return '' if $normalized eq '';
    return '/app/' . $normalized;
}

# _top_chrome_html($page, $urls)
# Builds the shared top-of-page chrome for edit and render views.
# Input: page document object and hash reference of edit/render/source URLs.
# Output: HTML snippet string.
sub _top_chrome_html {
    my ( $self, $page, $urls ) = @_;
    $urls ||= {};
    my $readonly = $self->_editor_disabled;
    my $share = $readonly ? '' : ( $urls->{edit}   || '' );
    my $play  = $readonly ? '' : ( $urls->{render} || '' );
    my $src   = $readonly ? '' : ( $urls->{source} || '' );
    my $mode  = $page->as_hash->{mode} || 'edit';
    my $ctx   = $page->{meta}{request_context} || {};
    my @links;
    push @links, qq{<a href="$play" id="play-url">Play</a>} if $mode ne 'render' && $play ne '';
    push @links, qq{<a href="$src" id="view-source-url">View Source</a>} if $mode ne 'edit' && $src ne '';
    push @links, q{<a href="/logout" id="logout-url">Logout</a>}
      if ( $ctx->{tier} || '' ) eq 'helper';
    my $nav = join ' ', @links;
    my $hide_indicators = $self->_indicators_disabled;
    my $status = $hide_indicators ? q{} : $self->_prompt_summary;
    my $context = $hide_indicators ? q{} : $self->_top_context_html($page);
    my $share_html = $share ne ''
      ? qq{<div><a href="$share" id="share-url">Right Click Copy &amp; Share or Bookmark This Page</a></div>}
      : q{};
    my $nav_html = $nav ne '' ? qq{<div style="margin-top:6px">$nav</div>} : q{};
    my $right_html = $hide_indicators
      ? q{}
      : sprintf(
        q{<div style="text-align:right;white-space:pre-wrap;font-family:'Segoe UI Emoji','Noto Color Emoji','Segoe UI Symbol',Georgia,'Times New Roman',serif">%s<span id="status-on-top">%s</span></div>},
        $context,
        $status,
      );
    my $script_html = $hide_indicators ? q{} : <<'HTML';
<script>
(function() {
  function renderTopStatus(items) {
    return (items || []).map(function(row) {
      return '<span id="status-' + row.prog + '">' + row.status + row.alias + '</span> ';
    }).join('');
  }
  function updateTopStatus(payload) {
    if (!payload || !payload.array) return;
    var top = document.getElementById('status-on-top');
    if (top) top.innerHTML = renderTopStatus(payload.array);
  }
  setInterval(function() {
    fetch('/system/status', { headers: { 'Accept': 'application/json' } })
      .then(function(res) { return res.json(); })
      .then(updateTopStatus)
      .catch(function() {});
  }, 5000);
  function pad(value) {
    return String(value).padStart(2, '0');
  }
  function updateDateTime() {
    var node = document.getElementById('status-datetime');
    if (!node) return;
    var now = new Date();
    node.textContent = now.getFullYear() + '-' +
      pad(now.getMonth() + 1) + '-' +
      pad(now.getDate()) + ' ' +
      pad(now.getHours()) + ':' +
      pad(now.getMinutes()) + ':' +
      pad(now.getSeconds());
  }
  updateDateTime();
  setInterval(updateDateTime, 1000);
})();
</script>
HTML
    return sprintf <<'HTML', $share_html, $nav_html, $right_html, $script_html;
<div class="dd-top-chrome" style="display:flex;justify-content:space-between;gap:16px;align-items:flex-start;margin-bottom:16px;padding-bottom:12px;border-bottom:1px solid #ddd3c2">
  <div>
    %s
    %s
  </div>
  %s
</div>
%s
HTML
}

# _top_context_html($page)
# Builds the old-style top-right user, host, and date context line for browser chrome.
# Input: page document object.
# Output: HTML snippet string.
sub _top_context_html {
    my ( $self, $page ) = @_;
    my $ctx = $page->{meta}{request_context} || {};
    my $user = (
        ( $ctx->{tier} || '' ) eq 'helper' && ( $ctx->{username} || '' ) ne ''
    ) ? $ctx->{username} : ( $ENV{USER} || eval { getpwuid($<) } || 'user' );
    my $host = $ctx->{host} || '';
    $host =~ s/^https?:\/\///;
    $host =~ s/\/.*$//;
    my ( $host_only, $port ) = split /:/, $host, 2;
    my $machine_ip = $self->_machine_ip || $host_only || $ctx->{remote_addr} || '127.0.0.1';
    my $host_href = 'http://' . $machine_ip . ( defined $port && $port ne '' ? ':' . $port : '' );
    my $now = strftime '%Y-%m-%d %H:%M:%S', localtime;
    return sprintf q{<span class="user-name-and-icon">&#128129;&#127996; %s</span> <span id="status-server">&#128187; <a href="%s">%s</a></span> &#128467; <span id="status-datetime">%s</span><br>},
      _escape_html($user),
      _escape_html($host_href),
      _escape_html($machine_ip),
      _escape_html($now);
}

# _machine_ip()
# Discovers the preferred machine IPv4 address, preferring VPN-style interfaces before other global addresses.
# Input: none.
# Output: IPv4 address string or undef when unavailable.
sub _machine_ip {
    my ($self) = @_;
    my @candidates = $self->_ip_candidates;
    return $candidates[0] if @candidates;
    return;
}

# _ip_candidates()
# Collects candidate machine IPv4 addresses from system network interfaces.
# Input: none.
# Output: ordered list of IPv4 address strings.
sub _ip_candidates {
    my ($self) = @_;
    my @pairs = $self->_ip_interface_pairs;
    my @vpn;
    my @preferred;
    my @other;
    for my $pair (@pairs) {
        push @other, $pair->{ip};
        push @vpn, $pair->{ip} if $pair->{iface} =~ /^(?:tun|tap|ppp|utun|wg|vpn)/i;
        push @preferred, $pair->{ip} if $pair->{iface} =~ /^(?:eth|en|wlan|wl)/i;
    }
    my %seen;
    return grep { !$seen{$_}++ } ( @vpn, @preferred, @other );
}

# _ip_interface_pairs()
# Reads active global IPv4 interface/address pairs from the host system.
# Input: none.
# Output: list of hashes with iface and ip keys.
sub _ip_interface_pairs {
    my ($self) = @_;
    my @pairs = $self->_ip_pairs_from_ip;
    return @pairs if @pairs;
    @pairs = $self->_ip_pairs_from_ipconfig;
    return @pairs if @pairs;
    return $self->_ip_pairs_from_ifconfig;
}

# _ip_pairs_from_ip()
# Reads IPv4 interface/address pairs using the `ip` command when available.
# Input: none.
# Output: list of hashes with iface and ip keys.
sub _ip_pairs_from_ip {
    my ($self) = @_;
    return () if !command_in_path('ip');
    my ( $stdout, undef, $exit_code ) = capture {
        system 'ip', '-o', '-4', 'addr', 'show', 'up', 'scope', 'global';
        return $? >> 8;
    };
    return () if $exit_code != 0 || !defined $stdout || $stdout eq '';
    my @pairs;
    for my $line ( split /\n/, $stdout ) {
        next if $line !~ /^\d+:\s+([^ ]+)\s+inet\s+(\d+\.\d+\.\d+\.\d+)\//;
        push @pairs, { iface => $1, ip => $2 };
    }
    return @pairs;
}

# _ip_pairs_from_ipconfig()
# Reads IPv4 interface/address pairs using Windows ipconfig output when available.
# Input: none.
# Output: list of hashes with iface and ip keys.
sub _ip_pairs_from_ipconfig {
    my ($self) = @_;
    return () if !command_in_path('ipconfig');
    my ( $stdout, undef, $exit_code ) = capture {
        system 'ipconfig';
        return $? >> 8;
    };
    return () if $exit_code != 0 || !defined $stdout || $stdout eq '';
    my @pairs;
    my $iface = '';
    for my $line ( split /\n/, $stdout ) {
        if ( $line =~ /^\S.*adapter\s+(.+?):\s*$/i ) {
            $iface = $1;
            next;
        }
        next if $iface eq '';
        next if $line !~ /IPv4[^:]*:\s*(\d+\.\d+\.\d+\.\d+)/i;
        my $ip = $1;
        next if $ip =~ /^127\./;
        push @pairs, { iface => $iface, ip => $ip };
    }
    return @pairs;
}

# _ip_pairs_from_ifconfig()
# Reads IPv4 interface/address pairs using `ifconfig` as a fallback on systems without `ip`.
# Input: none.
# Output: list of hashes with iface and ip keys.
sub _ip_pairs_from_ifconfig {
    my ($self) = @_;
    return () if !command_in_path('ifconfig');
    my ( $stdout, undef, $exit_code ) = capture {
        system 'ifconfig';
        return $? >> 8;
    };
    return () if $exit_code != 0 || !defined $stdout || $stdout eq '';
    my @pairs;
    my $iface = '';
    for my $line ( split /\n/, $stdout ) {
        if ( $line =~ /^([A-Za-z0-9._:-]+):\s/ ) {
            $iface = $1;
            next;
        }
        next if $iface eq '';
        next if $line !~ /\binet\s+(?:addr:)?(\d+\.\d+\.\d+\.\d+)/;
        my $ip = $1;
        next if $ip =~ /^127\./;
        push @pairs, { iface => $iface, ip => $ip };
    }
    return @pairs;
}

# _prompt_summary()
# Renders the older page-header indicator summary for page chrome.
# Input: none.
# Output: short status strip string.
sub _prompt_summary {
    my ($self) = @_;
    my $payload = $self->_page_status_payload;
    my @items = @{ $payload->{array} || [] };
    my @parts;
    for my $item (@items) {
        push @parts, $item->{status} . $item->{alias};
    }
    return join ' ', @parts;
}

# _page_status_payload()
# Builds the `/system/status` payload from the indicator store.
# Input: none.
# Output: hash reference with array, hash, and status maps.
sub _page_status_payload {
    my ($self) = @_;
    my $indicators = $self->{prompt} ? $self->{prompt}{indicators} : undef;
    return { array => [], hash => {}, status => {} } if !$indicators || !$indicators->can('page_header_payload');
    if ( $self->{config} && $self->{config}->can('collectors') ) {
        $indicators->sync_collectors( $self->{config}->collectors );
    }
    return $indicators->page_header_payload;
}

# _session_cookie($session_id)
# Builds the Set-Cookie header value for a dashboard session.
# Input: session id string.
# Output: cookie header string.
sub _session_cookie {
    my ($session_id) = @_;
    return "dashboard_session=$session_id; Path=/; HttpOnly; SameSite=Strict";
}

# _expired_session_cookie()
# Builds a Set-Cookie header that expires the dashboard session cookie.
# Input: none.
# Output: cookie header string.
sub _expired_session_cookie {
    return 'dashboard_session=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0';
}

# _serve_static_file($type, $filename)
# Serves static files from the public directory (js, css, others).
# Input: $type (js, css, or others), $filename (requested filename).
# Output: array reference of status code, content type, body.
sub _serve_static_file {
    my ( $self, $type, $filename ) = @_;
    return $self->_serve_static_file_from_roots( $type, $filename, $self->_static_file_roots($type) );
}

# _serve_static_file_from_roots($type, $filename, @roots)
# Serves one static file from an explicit ordered list of public roots.
# Input: asset type string, relative filename, and candidate directory roots.
# Output: array reference of status code, content type, and body.
sub _serve_static_file_from_roots {
    my ( $self, $type, $filename, @public_roots ) = @_;

    # Prevent directory traversal attacks
    return [ 400, 'text/plain; charset=utf-8', "Bad Request\n" ]
        if $filename =~ /\.\./;

    my $file_path = '';
    for my $public_dir (@public_roots) {
        my $candidate = File::Spec->catfile( $public_dir, $filename );
        my $real_path = eval { File::Spec->rel2abs($candidate) } || '';
        my $quoted_public = quotemeta($public_dir);
        next if $real_path !~ /^$quoted_public(?:\/|\z)/;
        next if !-f $candidate || !-r $candidate;
        $file_path = $candidate;
        last;
    }
    return [ 404, 'text/plain; charset=utf-8', "Not Found\n" ] if $file_path eq '';

    return $self->_serve_static_file_at_path( $type, $filename, $file_path );
}

# _skill_ajax_file_path($skill_name, $ajax_file)
# Resolves one layered skill-local dashboards/ajax file path through the skill dispatcher.
# Input: skill name string and relative ajax file name.
# Output: absolute file path string or empty string.
sub _skill_ajax_file_path {
    my ( $self, $skill_name, $ajax_file ) = @_;
    return '' if !$skill_name || !$ajax_file;
    return $self->_skill_dispatcher->skill_ajax_file_path( $skill_name, $ajax_file ) || '';
}

# _skill_static_file_path($skill_name, $type, $file)
# Resolves one layered skill-local dashboards/public asset path through the skill dispatcher.
# Input: skill name string, asset type string, and relative file path.
# Output: absolute file path string or empty string.
sub _skill_static_file_path {
    my ( $self, $skill_name, $type, $file ) = @_;
    return '' if !$skill_name || !$type || !$file;
    return $self->_skill_dispatcher->skill_static_file_path( $skill_name, $type, $file ) || '';
}

# _static_file_roots($type)
# Returns candidate public directories for static file serving in lookup order.
# Input: asset type string such as js, css, or others.
# Output: ordered list of directory path strings.
sub _static_file_roots {
    my ( $self, $type ) = @_;
    my @roots;
    my %seen;

    my $paths = $self->{pages} && ref( $self->{pages} ) eq 'Developer::Dashboard::PageStore'
      ? $self->{pages}{paths}
      : undef;
    if ($paths) {
        for my $runtime_root ( $paths->runtime_roots ) {
            my $root = File::Spec->catdir( $runtime_root, 'dashboard', 'public', $type );
            push @roots, $root if !$seen{$root}++;
        }
        for my $dashboards_root ( $paths->dashboards_roots ) {
            my $root = File::Spec->catdir( $dashboards_root, 'public', $type );
            push @roots, $root if !$seen{$root}++;
        }
    }

    my $home_root = File::Spec->catdir(
        $ENV{HOME} || $ENV{USERPROFILE} || '/root',
        '.developer-dashboard',
        'dashboard',
        'public',
        $type
    );
    push @roots, $home_root if !$seen{$home_root}++;
    return @roots;
}

# _serve_static_file_at_path($type, $filename, $file_path, $default_type)
# Serves one already-resolved static file path after the caller has chosen the lookup source.
# Input: asset type string, request filename string, resolved file path string, and optional explicit mime type override.
# Output: array reference of status code, content type, and body.
sub _serve_static_file_at_path {
    my ( $self, $type, $filename, $file_path, $default_type ) = @_;
    return [ 404, 'text/plain; charset=utf-8', "Not Found\n" ]
      if !defined $file_path || $file_path eq '' || !-f $file_path || !-r $file_path;
    my $content_type = defined $default_type && $default_type ne ''
      ? _ajax_content_type($default_type)
      : $self->_get_content_type( $type, $filename );
    open my $fh, '<', $file_path or return [ 500, 'text/plain; charset=utf-8', "Internal Server Error\n" ];
    my $content = do { local $/; <$fh> };
    close $fh;
    return [ 200, $content_type, $content ];
}

# _get_content_type($type, $filename)
# Determines the MIME type based on file type and extension.
# Input: $type (js, css, or others), $filename (requested filename).
# Output: MIME type string.
sub _get_content_type {
    my ( $self, $type, $filename ) = @_;

    if ( $type eq 'js' ) {
        return 'application/javascript; charset=utf-8';
    }
    elsif ( $type eq 'css' ) {
        return 'text/css; charset=utf-8';
    }
    elsif ( $filename =~ /\.json$/i ) {
        return 'application/json; charset=utf-8';
    }
    elsif ( $filename =~ /\.xml$/i ) {
        return 'application/xml; charset=utf-8';
    }
    elsif ( $filename =~ /\.txt$/i ) {
        return 'text/plain; charset=utf-8';
    }
    elsif ( $filename =~ /\.html?$/i ) {
        return 'text/html; charset=utf-8';
    }
    elsif ( $filename =~ /\.svg$/i ) {
        return 'image/svg+xml';
    }
    elsif ( $filename =~ /\.png$/i ) {
        return 'image/png';
    }
    elsif ( $filename =~ /\.jpe?g$/i ) {
        return 'image/jpeg';
    }
    elsif ( $filename =~ /\.gif$/i ) {
        return 'image/gif';
    }
    elsif ( $filename =~ /\.webp$/i ) {
        return 'image/webp';
    }
    elsif ( $filename =~ /\.ico$/i ) {
        return 'image/x-icon';
    }
    else {
        return 'application/octet-stream';
    }
}

1;

__END__

=head1 NAME

Developer::Dashboard::Web::App - local web application for Developer Dashboard

=head1 SYNOPSIS

  my $app = Developer::Dashboard::Web::App->new(
      auth     => $auth,
      pages    => $pages,
      sessions => $sessions,
  );

=head1 DESCRIPTION

This module handles the browser-facing dashboard routes, helper login flow,
page rendering modes, and page/action execution endpoints. It also provides
static file serving for JavaScript, CSS, and other assets from the public
directory structure (~/.developer-dashboard/dashboard/public/{js,css,others}).

=head1 METHODS

=head2 new, handle

Construct and dispatch the local web application.

=head2 _serve_static_file($type, $filename)

Serves static files from the public directory.

Input: $type (js, css, or others subdirectory), $filename (requested filename).
Output: array reference of [status_code, content_type, body].

Security: Prevents directory traversal attacks and verifies files are within
the public directory before serving.

=head2 _get_content_type($type, $filename)

Determines the MIME type for a file based on its type and extension.

Input: $type (js, css, or others), $filename (requested filename).
Output: MIME type string suitable for Content-Type header.

Supports: JS, CSS, JSON, XML, HTML, SVG, PNG, JPEG, GIF, WebP, ICO, and others.

=for comment FULL-POD-DOC START

=head1 PURPOSE

This module is the main route backend for the browser application. It handles login and logout, saved and transient page render/source/edit routes, status endpoints, saved Ajax endpoints, and the auth checks that decide whether a request is local admin, helper user, or unauthorized outsider.

=head1 WHY IT EXISTS

It exists because the dashboard browser surface is large and security-sensitive. Centralizing route behavior, auth gating, saved-page handling, Ajax endpoints, and response shaping keeps the product behavior coherent and testable.

=head1 WHEN TO USE

Use this file when changing browser routes, helper login behavior, page render/source/edit flows, saved Ajax endpoints, or the runtime JSON and HTML responses for dashboard workspaces.

=head1 HOW TO USE

Construct it with the action runner, auth service, page store, prompt, page resolver, page runtime, and session store, then hand it to the Dancer adapter or PSGI bootstrap. Route-specific behavior belongs here rather than in the transport wrapper.

=head1 WHAT USES IT

It is used by C<Developer::Dashboard::Web::DancerApp>, by C<app.psgi>, by the CLI web server wrapper, and by the broad web/browser regression suite that covers routes, auth, Ajax, and workspace behavior.

=head1 EXAMPLES

Example 1:

  perl -Ilib -MDeveloper::Dashboard::Web::App -e 1

Do a direct compile-and-load check against the module from a source checkout.

Example 2:

  prove -lv t/03-web-app.t t/08-web-update-coverage.t t/web_app_static_files.t

Run the focused regression tests that most directly exercise this module's behavior.

Example 3:

  HARNESS_PERL_SWITCHES=-MDevel::Cover prove -lr t

Recheck the module under the repository coverage gate rather than relying on a load-only probe.

Example 4:

  prove -lr t

Put any module-level change back through the entire repository suite before release.


=for comment FULL-POD-DOC END

=cut
