package Developer::Dashboard::PathRegistry;

use strict;
use warnings;

our $VERSION = '4.26';

use Digest::MD5 qw(md5_hex);
use Cwd qw(abs_path getcwd);
use File::Basename qw(dirname);
use File::Path qw(make_path);
use File::Spec;
use Developer::Dashboard::JSON qw(json_encode);
use Developer::Dashboard::Platform qw(passwd_user_name);

# _resolved_home_from_env()
# Resolves the current user home directory from the available process
# environment in a Windows-safe order.
# Input: none.
# Output: home directory path string or undef when no supported home variables exist.
sub _resolved_home_from_env {
    my $home = $ENV{HOME};
    return $home if defined $home && $home ne '';

    my $userprofile = $ENV{USERPROFILE};
    return $userprofile if defined $userprofile && $userprofile ne '';

    my $homedrive = $ENV{HOMEDRIVE};
    my $homepath  = $ENV{HOMEPATH};
    if ( defined $homedrive && $homedrive ne '' && defined $homepath && $homepath ne '' ) {
        return File::Spec->catdir( $homedrive, $homepath );
    }

    return undef;
}

# new(%args)
# Constructs the logical path registry for the runtime.
# Input: home, app_name, workspace_roots, project_roots, and named_paths.
# Output: Developer::Dashboard::PathRegistry object.
sub new {
    my ( $class, %args ) = @_;

    my $home = $args{home} || _resolved_home_from_env() || die 'Missing home directory';

    my $self = bless {
        home            => $home,
        app_name        => $args{app_name} || 'developer-dashboard',
        cwd             => $args{cwd},
        memo            => {},
        workspace_roots => $args{workspace_roots} || [],
        project_roots   => $args{project_roots} || [],
        named_paths     => $args{named_paths} || {},
    }, $class;

    return $self;
}

# new_from_all_folders()
# Constructs a path registry from the public Folder compatibility inventory.
# Input: none.
# Output: Developer::Dashboard::PathRegistry object.
sub new_from_all_folders {
    my ($class) = @_;
    require Developer::Dashboard::Folder;
    return $class->new( %{ Developer::Dashboard::Folder->all } );
}

# home()
# Returns the configured home directory.
# Input: none.
# Output: home directory path string.
sub home { $_[0]->{home} }

# app_name()
# Returns the runtime application name.
# Input: none.
# Output: application name string.
sub app_name { $_[0]->{app_name} }

# register_named_paths($paths)
# Registers additional logical path aliases.
# Input: hash reference of alias-to-path mappings.
# Output: registry object.
sub register_named_paths {
    my ( $self, $paths ) = @_;
    return $self if ref($paths) ne 'HASH';
    for my $name ( keys %$paths ) {
        next if !defined $name || $name eq '';    # uncoverable condition left
        $self->{named_paths}{$name} = $paths->{$name};
    }
    return $self;
}

# unregister_named_path($name)
# Removes a logical path alias from the in-memory registry when present.
# Input: alias name string.
# Output: registry object.
sub unregister_named_path {
    my ( $self, $name ) = @_;
    return $self if !defined $name || $name eq '';
    delete $self->{named_paths}{$name};
    return $self;
}

# named_paths()
# Returns the currently registered logical path aliases.
# Input: none.
# Output: hash reference of alias-to-path mappings.
sub named_paths {
    my ($self) = @_;
    return { %{ $self->{named_paths} || {} } };    # uncoverable branch true
}

# all_paths() and all_path_aliases()
# Return the public hash payloads for the same inventories printed by
# dashboard paths and dashboard path list.
# Input: none.
# Output: hash references describing resolved runtime roots and aliases.

# runtime_root()
# Returns the effective runtime root directory.
# Input: none.
# Output: deepest discovered runtime layer directory, falling back to the home runtime directory.
sub runtime_root {
    my ($self) = @_;
    my @layers = $self->runtime_layers;
    return $layers[-1];
}

# home_runtime_root()
# Returns the home-backed runtime root directory.
# Input: none.
# Output: home runtime directory path string.
sub home_runtime_root {
    my ($self) = @_;
    return $self->_ensure_dir( $self->home_runtime_path );
}

# home_runtime_path()
# Returns the canonical home-backed runtime root path without creating it.
# Input: none.
# Output: home runtime directory path string.
sub home_runtime_path {
    my ($self) = @_;
    return File::Spec->catdir( $self->home, '.developer-dashboard' );
}

# project_runtime_root()
# Returns the active project-local runtime root when the project already contains one.
# Input: none.
# Output: project-local runtime directory path string or undef when absent.
sub project_runtime_root {
    my ($self) = @_;
    my $repo = $self->current_project_root or return;
    my $home_runtime = File::Spec->catdir( $self->home, '.developer-dashboard' );
    return if $repo eq $home_runtime;
    my $root = File::Spec->catdir( $repo, '.developer-dashboard' );
    return -d $root ? $root : undef;
}

# runtime_roots()
# Returns the effective runtime roots in lookup order.
# Input: none.
# Output: ordered list of runtime root directory path strings from deepest layer to home fallback.
sub runtime_roots {
    my ($self) = @_;
    return reverse $self->runtime_layers;
}

# runtime_layers()
# Returns the effective runtime roots in inheritance order from home to the
# current working directory layer.
# Input: none.
# Output: ordered list of runtime root directory path strings from home to deepest layer.
sub runtime_layers {
    my ($self) = @_;
    my $cwd = $self->current_working_directory;
    my $cache_key = 'runtime_layers:' . $self->_path_identity( defined $cwd ? $cwd : '' );
    return @{ $self->_memoize(
            $cache_key => sub {
                my @roots;
                my %seen;
                for my $root ( $self->_runtime_layers_from_env, $self->home_runtime_root, $self->_ancestor_runtime_layers ) {
                    next if $root eq '';    # uncoverable branch true
                    my $identity = $self->_path_identity($root);
                    next if $seen{$identity}++;
                    push @roots, $root;
                }
                return \@roots;
            }
        )
    };
}

# state_root()
# Returns the state root directory.
# Input: none.
# Output: directory path string.
sub state_root {
    my ($self) = @_;
    my $dir = $self->_ensure_state_dir( File::Spec->catdir( $self->state_base_root, $self->_state_root_key( $self->runtime_root ) ) );
    $self->_write_state_metadata( $dir, $self->runtime_root );
    return $dir;
}

# state_base_root()
# Returns the stable base directory for runtime state roots.
# Input: none.
# Output: directory path string.
sub state_base_root {
    my ($self) = @_;
    if ( my $root = $ENV{DEVELOPER_DASHBOARD_STATE_ROOT} ) {
        return $self->_ensure_state_dir( $self->_expand_home($root) );
    }
    return $self->_ensure_state_dir(
        File::Spec->catdir( File::Spec->tmpdir, $self->_state_root_user, $self->app_name, 'state' )
    );
}

# cache_root()
# Returns the cache root directory.
# Input: none.
# Output: directory path string.
sub cache_root {
    my ($self) = @_;
    return $self->_ensure_dir( File::Spec->catdir( $self->runtime_root, 'cache' ) );
}

# home_cache_root()
# Returns the home-layer cache root directory, ignoring any deeper runtime
# layer. Per-user artifacts that a fixed external consumer reads from one
# well-known path - such as the generated shell-startup caches a login profile
# dot-sources - must not follow the working directory into a project layer,
# because the consumer would then read a cache no refresh ever rewrites.
# Input: none.
# Output: directory path string.
sub home_cache_root {
    my ($self) = @_;
    return $self->_ensure_dir( File::Spec->catdir( $self->home_runtime_root, 'cache' ) );
}

# logs_root()
# Returns the logs root directory.
# Input: none.
# Output: directory path string.
sub logs_root {
    my ($self) = @_;
    return $self->_ensure_dir( File::Spec->catdir( $self->runtime_root, 'logs' ) );
}

# dashboards_root()
# Returns the dashboards/bookmarks root directory.
# Input: none.
# Output: directory path string.
sub dashboards_root {
    my ($self) = @_;
    if ( my $dir = $ENV{DEVELOPER_DASHBOARD_BOOKMARKS} ) {
        return $self->_ensure_dir( $self->_expand_home($dir) );
    }
    return $self->_ensure_dir( File::Spec->catdir( $self->runtime_root, 'dashboards' ) );
}

# dashboards_roots()
# Returns the bookmark roots in effective lookup order.
# Input: none.
# Output: ordered list of bookmark root directory path strings.
sub dashboards_roots {
    my ($self) = @_;
    if ( my $dir = $ENV{DEVELOPER_DASHBOARD_BOOKMARKS} ) {
        return ( $self->_ensure_dir( $self->_expand_home($dir) ) );
    }
    return map { File::Spec->catdir( $_, 'dashboards' ) } $self->runtime_roots;
}

# dashboards_layers()
# Returns the bookmark roots in inheritance order from home to deepest layer.
# Input: none.
# Output: ordered list of bookmark root directory path strings.
sub dashboards_layers {
    my ($self) = @_;
    if ( my $dir = $ENV{DEVELOPER_DASHBOARD_BOOKMARKS} ) {
        return ( $self->_ensure_dir( $self->_expand_home($dir) ) );
    }
    return map { File::Spec->catdir( $_, 'dashboards' ) } $self->runtime_layers;
}

# bookmarks()
# Returns the older bookmark directory alias.
# Input: none.
# Output: directory path string.
sub bookmarks {
    my ($self) = @_;
    return $self->dashboards_root;
}

# bookmarks_root()
# Returns the older bookmark root alias.
# Input: none.
# Output: directory path string.
sub bookmarks_root {
    my ($self) = @_;
    return $self->dashboards_root;
}

# all_paths()
# Returns the full runtime path inventory used by C<dashboard paths>.
# Input: none.
# Output: hash reference describing the active runtime path set plus named aliases.
sub all_paths {
    my ($self) = @_;
    return {
        home                 => $self->home,
        home_runtime_root    => $self->home_runtime_root,
        project_runtime_root => scalar $self->project_runtime_root,
        runtime_root         => $self->runtime_root,
        state_root           => $self->state_root,
        cache_root           => $self->cache_root,
        logs_root            => $self->logs_root,
        dashboards_root      => $self->dashboards_root,
        bookmarks_root       => $self->bookmarks_root,
        cli_root             => $self->cli_root,
        collectors_root      => $self->collectors_root,
        indicators_root      => $self->indicators_root,
        config_root          => $self->config_root,
        current_project_root => scalar $self->current_project_root,
        %{ $self->named_paths },
    };
}

# all_path_aliases()
# Returns the runtime path inventory used by C<dashboard path list>.
# Input: none.
# Output: hash reference of standard runtime aliases plus named aliases.
sub all_path_aliases {
    my ($self) = @_;
    return {
        home            => $self->home,
        home_runtime    => $self->home_runtime_root,
        project_runtime => scalar $self->project_runtime_root,
        runtime         => $self->runtime_root,
        state           => $self->state_root,
        cache           => $self->cache_root,
        logs            => $self->logs_root,
        dashboards      => $self->dashboards_root,
        bookmarks       => $self->bookmarks_root,
        cli             => $self->cli_root,
        config          => $self->config_root,
        collectors      => $self->collectors_root,
        indicators      => $self->indicators_root,
        %{ $self->named_paths },
    };
}

# cli_root()
# Returns the user CLI extension directory.
# Input: none.
# Output: directory path string.
sub cli_root {
    my ($self) = @_;
    return $self->_ensure_dir( File::Spec->catdir( $self->runtime_root, 'cli' ) );
}

# cli_roots()
# Returns the CLI extension roots in effective lookup order.
# Input: none.
# Output: ordered list of CLI root directory path strings.
sub cli_roots {
    my ($self) = @_;
    return map { File::Spec->catdir( $_, 'cli' ) } $self->runtime_roots;
}

# cli_layers()
# Returns the CLI extension roots in inheritance order from home to deepest
# layer.
# Input: none.
# Output: ordered list of CLI root directory path strings.
sub cli_layers {
    my ($self) = @_;
    return map { File::Spec->catdir( $_, 'cli' ) } $self->runtime_layers;
}

# skills_root()
# Returns the writable skill root for the deepest participating DD-OOP-LAYER.
# Input: none.
# Output: directory path string.
sub skills_root {
    my ($self) = @_;
    return $self->_ensure_dir( File::Spec->catdir( $self->runtime_root, 'skills' ) );
}

# skills_roots()
# Returns the installed skill roots in effective lookup order from deepest layer to home.
# Input: none.
# Output: ordered list of skill root directory path strings.
sub skills_roots {
    my ($self) = @_;
    return map { File::Spec->catdir( $_, 'skills' ) } $self->runtime_roots;
}

# validated_path_segments($path)
# Splits one untrusted relative path into its separator-delimited segments and
# rejects every shape that could escape the directory the caller joins it onto,
# because File::Spec->catfile/catdir never collapse parent-directory components.
# Input: candidate relative path string sourced from browser or CLI input.
# Output: list of validated segments (segment count in scalar context), or an
# empty list (undef in scalar context) when the path is missing, contains a NUL
# or other control byte, a backslash separator, is absolute, carries a Windows
# drive prefix, or holds any empty, current-directory, or parent-directory
# segment.
sub validated_path_segments {
    my ($path) = @_;
    return if !defined $path || $path eq '';
    return if $path =~ /[\x00-\x1F\x7F]/;
    return if $path =~ /\\/;
    return if File::Spec->file_name_is_absolute($path);
    return if $path =~ /\A[A-Za-z]:/;
    my @segments = split m{/+}, $path, -1;
    return if grep { $_ eq '' || $_ eq '.' || $_ eq '..' } @segments;
    return @segments;
}

# skill_root($name)
# Returns the isolated root directory for one installed skill.
# Input: skill repository name string.
# Output: directory path string.
sub skill_root {
    my ( $self, $name ) = @_;
    die 'Missing skill name' if !defined $name || $name eq '';
    return $self->_ensure_dir( File::Spec->catdir( $self->skills_root, $name ) );
}

# skill_layers($name)
# Returns the installed roots for one skill in inheritance order from home to
# the deepest participating layer. A disabled deepest layer masks the whole
# skill from normal runtime lookup. The name is joined straight onto each
# skills root, so any name that is not exactly one validated path segment
# resolves to no layer at all instead of escaping the skills tree.
# Input: skill repository name string and optional include_disabled flag.
# Output: ordered list of skill root directory path strings from home to leaf.
sub skill_layers {
    my ( $self, $name, %args ) = @_;
    return () if !defined $name || $name eq '';
    my @name_segments = validated_path_segments($name);
    return () if @name_segments != 1;

    my @matches;
    my $saw_effective = 0;
    for my $skills_root ( $self->skills_roots ) {
        my $skill_root = File::Spec->catdir( $skills_root, $name );
        next if !-d $skill_root;
        my $disabled = -f File::Spec->catfile( $skill_root, '.disabled' ) ? 1 : 0;
        if ( !$saw_effective++ ) {
            return () if !$args{include_disabled} && $disabled;
        }
        next if !$args{include_disabled} && $disabled;
        push @matches, $skill_root;
    }

    return reverse @matches;
}

# skill_roots_for($name)
# Returns the installed roots for one skill in lookup order from the deepest
# participating layer back to home.
# Input: skill repository name string and optional include_disabled flag.
# Output: ordered list of skill root directory path strings from leaf to home.
sub skill_roots_for {
    my ( $self, $name, %args ) = @_;
    return reverse $self->skill_layers( $name, %args );
}

# installed_skill_roots()
# Returns every installed skill root in deterministic sorted order.
# Input: none.
# Output: ordered list of installed skill root directory path strings.
sub installed_skill_roots {
    my ( $self, %args ) = @_;
    my @roots;
    my %seen_names;
    for my $skills_root ( $self->skills_roots ) {
        next if !-d $skills_root;
        opendir my $dh, $skills_root or die "Unable to read $skills_root: $!";    # uncoverable branch true
        for my $entry (
            sort grep {
                   $_ ne '.'
                && $_ ne '..'
                && -d File::Spec->catdir( $skills_root, $_ )
            } readdir $dh
          )
        {
            next if $seen_names{$entry}++;
            my $skill_root = File::Spec->catdir( $skills_root, $entry );
            my $disabled = -f File::Spec->catfile( $skill_root, '.disabled' ) ? 1 : 0;
            next if !$args{include_disabled} && $disabled;
            push @roots, $skill_root;
        }
        closedir $dh;
    }
    return @roots;
}

# installed_skill_docker_roots()
# Returns the config/docker roots contributed by installed skills in deterministic sorted order.
# Input: none.
# Output: ordered list of skill docker configuration root directory path strings.
sub installed_skill_docker_roots {
    my ( $self, %args ) = @_;
    return map { File::Spec->catdir( $_, 'config', 'docker' ) } $self->installed_skill_roots(%args);
}

# installed_skill_docker_roots_for_runtime($runtime_root)
# Returns the effective installed skill docker roots that belong to one runtime layer.
# Input: runtime root directory path and optional include_disabled flag.
# Output: ordered list of skill docker configuration root directory path strings for that layer.
sub installed_skill_docker_roots_for_runtime {
    my ( $self, $runtime_root, %args ) = @_;
    return () if !defined $runtime_root || $runtime_root eq '';
    my $skills_root = File::Spec->catdir( $runtime_root, 'skills' );
    my $prefix = $skills_root . '/';
    return map { File::Spec->catdir( $_, 'config', 'docker' ) }
      grep {
            my $path = $_;
            $path eq $skills_root || index( $path, $prefix ) == 0;    # uncoverable branch false
      } $self->installed_skill_roots(%args);
}

# collectors_root()
# Returns the collectors state root directory.
# Input: none.
# Output: directory path string.
sub collectors_root {
    my ($self) = @_;
    return $self->_ensure_state_dir( File::Spec->catdir( $self->state_root, 'collectors' ) );
}

# collectors_roots()
# Returns the collector state roots in lookup order from deepest layer to home.
# Input: none.
# Output: ordered list of collector state root directory path strings.
sub collectors_roots {
    my ($self) = @_;
    return map {
        $self->_ensure_state_dir( File::Spec->catdir( $self->_state_root_for_layer($_), 'collectors' ) )
    } $self->runtime_roots;
}

# indicators_root()
# Returns the indicators state root directory.
# Input: none.
# Output: directory path string.
sub indicators_root {
    my ($self) = @_;
    return $self->_ensure_state_dir( File::Spec->catdir( $self->state_root, 'indicators' ) );
}

# indicators_roots()
# Returns the indicator state roots in lookup order from deepest layer to home.
# Input: none.
# Output: ordered list of indicator state root directory path strings.
sub indicators_roots {
    my ($self) = @_;
    return map {
        $self->_ensure_state_dir( File::Spec->catdir( $self->_state_root_for_layer($_), 'indicators' ) )
    } $self->runtime_roots;
}

# sessions_root()
# Returns the sessions state root directory.
# Input: none.
# Output: directory path string.
sub sessions_root {
    my ($self) = @_;
    return $self->_ensure_state_dir( File::Spec->catdir( $self->state_root, 'sessions' ) );
}

# sessions_roots()
# Returns the session storage roots in effective lookup order.
# Input: none.
# Output: ordered list of session root directory path strings.
sub sessions_roots {
    my ($self) = @_;
    return map {
        $self->_ensure_state_dir( File::Spec->catdir( $self->_state_root_for_layer($_), 'sessions' ) )
    } $self->runtime_roots;
}

# _state_root_key($runtime_root)
# Returns the key for a runtime layer-specific state directory.
# Input: runtime layer path string.
# Output: directory name string.
sub _state_root_key {
    my ( $self, $runtime_root ) = @_;
    my $identity = $self->_path_identity($runtime_root);
    return md5_hex( defined $identity ? $identity : '' );    # uncoverable branch false
}

# _state_root_user()
# Returns a sanitized username used to namespace runtime state roots in the shared temp area.
# The chain reads the interactive Unix variables, then the Windows USERNAME
# variable a non-interactive Windows session still carries, then the guarded
# passwd lookup, so no runtime can abort here.
# Input: none.
# Output: username string.
sub _state_root_user {
    my ($self) = @_;
    my $raw =
         $ENV{DD_STATE_ROOT_USER}
      || $ENV{USER}
      || $ENV{LOGNAME}
      || $ENV{USERNAME}
      || passwd_user_name($<);
    $raw = 'user' if !$raw;
    $raw =~ s{[^A-Za-z0-9._-]}{_}g;
    return $raw;
}

# _state_root_for_layer($runtime_root)
# Returns the full state root for a runtime layer.
# Input: runtime layer path string.
# Output: directory path string.
sub _state_root_for_layer {
    my ( $self, $runtime_root ) = @_;
    my $dir = File::Spec->catdir( $self->state_base_root, $self->_state_root_key($runtime_root) );
    $self->_ensure_state_dir($dir);
    $self->_write_state_metadata( $dir, $runtime_root );
    return $dir;
}

# _write_state_metadata($dir, $runtime_root)
# Records the runtime identity for one hashed temp-state root, recreating the
# hashed directory first if temp cleanup removed it.
# Input: state root directory path and originating runtime root path.
# Output: metadata file path string.
sub _write_state_metadata {
    my ( $self, $dir, $runtime_root ) = @_;
    return '' if !defined $dir || $dir eq '';
    return '' if !defined $runtime_root || $runtime_root eq '';
    $self->_ensure_state_dir($dir);
    my $file = File::Spec->catfile( $dir, 'runtime.json' );
    open my $fh, '>:raw', $file or die "Unable to write $file: $!";    # uncoverable branch true
    print {$fh} json_encode(
        {
            runtime_root => $runtime_root,
            app_name     => $self->app_name,
        }
    );
    close $fh or die "Unable to close $file: $!";    # uncoverable branch true
    $self->secure_file_permissions($file);
    return $file;
}

# temp_root()
# Returns the runtime temporary directory.
# Input: none.
# Output: directory path string.
sub temp_root {
    my ($self) = @_;
    return $self->_ensure_dir( File::Spec->catdir( $self->runtime_root, 'tmp' ) );
}

# config_root()
# Returns the configuration root directory.
# Input: none.
# Output: directory path string.
sub config_root {
    my ($self) = @_;
    if ( my $dir = $ENV{DEVELOPER_DASHBOARD_CONFIGS} ) {
        return $self->_ensure_dir( $self->_expand_home($dir) );
    }
    return $self->_ensure_dir( File::Spec->catdir( $self->runtime_root, 'config' ) );
}

# config_roots()
# Returns the configuration roots in effective lookup order.
# Input: none.
# Output: ordered list of configuration root directory path strings.
sub config_roots {
    my ($self) = @_;
    if ( my $dir = $ENV{DEVELOPER_DASHBOARD_CONFIGS} ) {
        return ( $self->_ensure_dir( $self->_expand_home($dir) ) );
    }
    return map { File::Spec->catdir( $_, 'config' ) } $self->runtime_roots;
}

# config_layers()
# Returns the configuration roots in inheritance order from home to deepest
# layer.
# Input: none.
# Output: ordered list of configuration root directory path strings.
sub config_layers {
    my ($self) = @_;
    if ( my $dir = $ENV{DEVELOPER_DASHBOARD_CONFIGS} ) {
        return ( $self->_ensure_dir( $self->_expand_home($dir) ) );
    }
    return map { File::Spec->catdir( $_, 'config' ) } $self->runtime_layers;
}

# auth_root()
# Returns the auth configuration directory.
# Input: none.
# Output: directory path string.
sub auth_root {
    my ($self) = @_;
    return $self->_ensure_dir( File::Spec->catdir( $self->config_root, 'auth' ) );
}

# auth_roots()
# Returns the auth configuration roots in effective lookup order.
# Input: none.
# Output: ordered list of auth root directory path strings.
sub auth_roots {
    my ($self) = @_;
    return map { File::Spec->catdir( $_, 'auth' ) } $self->config_roots;
}

# repo_dashboard_root()
# Returns the repo-local dashboard root for the active project.
# Input: none.
# Output: directory path string or undef.
sub repo_dashboard_root {
    my ($self) = @_;
    return $self->project_runtime_root;
}

# users_root()
# Returns the helper user storage directory.
# Input: none.
# Output: directory path string.
sub users_root {
    my ($self) = @_;
    return $self->_ensure_dir( File::Spec->catdir( $self->auth_root, 'users' ) );
}

# users_roots()
# Returns the helper-user roots in effective lookup order.
# Input: none.
# Output: ordered list of user storage directory path strings.
sub users_roots {
    my ($self) = @_;
    return map { File::Spec->catdir( $_, 'users' ) } $self->auth_roots;
}

# runtime_local_lib_roots()
# Returns the runtime-local Perl library roots in lookup order from deepest
# layer to home.
# Input: none.
# Output: ordered list of runtime-local perl5 directory paths.
sub runtime_local_lib_roots {
    my ($self) = @_;
    return map { File::Spec->catdir( $_, 'local', 'lib', 'perl5' ) } $self->runtime_roots;
}

# current_project_root()
# Resolves the current git project root from the current working directory.
# Input: none.
# Output: directory path string or undef.
sub current_project_root {
    my ($self) = @_;
    my $cwd = $self->current_working_directory;
    my $cache_key = 'current_project_root:' . $self->_path_identity($cwd);
    return $self->_memoize( $cache_key => sub { $self->project_root_for($cwd) } );
}

# current_working_directory()
# Returns the invocation-scoped current working directory used for path and
# runtime-layer derivation.
# Input: none.
# Output: directory path string or undef when cwd is unavailable.
sub current_working_directory {
    my ($self) = @_;
    return $self->{cwd} if defined $self->{cwd} && $self->{cwd} ne '';
    return eval { getcwd() };
}

# cwd()
# Preserves the public path-registry working-directory accessor that was
# historically supplied incidentally by importing Cwd::cwd into this package.
# Input: none.
# Output: the same directory path or undef as current_working_directory().
sub cwd {
    my ($self) = @_;
    return $self->current_working_directory;
}

# project_root_for($start_dir)
# Resolves the nearest git project root for a starting directory.
# Input: starting directory path.
# Output: directory path string or undef.
sub project_root_for {
    my ( $self, $start_dir ) = @_;

    my $dir = defined $start_dir && $start_dir ne '' ? $start_dir : $self->current_working_directory;
    return if !defined $dir || $dir eq '';

    while ($dir) {
        return $dir if -d File::Spec->catdir( $dir, '.git' );

        my $parent = dirname($dir);
        last if !$parent || $parent eq $dir;    # uncoverable condition left
        $dir = $parent;
    }

    return;
}

# workspace_roots()
# Returns the configured workspace search roots.
# Input: none.
# Output: list of directory path strings.
sub workspace_roots {
    my ($self) = @_;
    return @{ $self->{workspace_roots} };
}

# project_roots()
# Returns the configured project search roots.
# Input: none.
# Output: list of directory path strings.
sub project_roots {
    my ($self) = @_;
    return @{ $self->{project_roots} };
}

# resolve_dir($name)
# Resolves a logical directory name or absolute path.
# Input: logical directory name or absolute path.
# Output: resolved directory path string.
sub resolve_dir {
    my ( $self, $name ) = @_;

    die 'Missing path name' if !defined $name || $name eq '';

    return $name if File::Spec->file_name_is_absolute($name);

    return $self->$name() if $self->can($name);

    if ( exists $self->{named_paths}{$name} ) {
        my $path = $self->{named_paths}{$name};
        $path = $self->_expand_home($path);
        return $path;
    }

    die "Unknown directory name '$name'";
}

# resolve_any(@names)
# Resolves the first existing directory among several logical names.
# Input: list of logical directory names.
# Output: directory path string or undef.
sub resolve_any {
    my ( $self, @names ) = @_;
    for my $name (@names) {
        my $path = eval { $self->resolve_dir($name) };
        next if !$path || !-d $path;
        return $path;
    }
    return;
}

# ls($name)
# Lists child paths under a resolved directory.
# Input: logical directory name.
# Output: sorted list of child path strings.
sub ls {
    my ( $self, $name ) = @_;
    my $dir = $self->resolve_dir($name);
    return if !-d $dir;

    opendir my $dh, $dir or die "Unable to open $dir: $!";    # uncoverable branch true
    my @items;
    while ( my $entry = readdir $dh ) {
        next if $entry eq '.' || $entry eq '..';
        push @items, File::Spec->catfile( $dir, $entry );
    }
    closedir $dh;

    return sort @items;
}

# with_dir($name, $code)
# Temporarily changes into a resolved directory while executing a callback.
# Input: logical directory name and code reference.
# Output: callback return value.
sub with_dir {
    my ( $self, $name, $code ) = @_;
    my $dir = $self->resolve_dir($name);
    my $old = getcwd();
    chdir $dir or die "Unable to chdir to $dir: $!";    # uncoverable branch true
    my @result = eval { $code->($dir) };
    my $error = $@;
    chdir $old or die "Unable to restore cwd to $old: $!";    # uncoverable branch true
    die $error if $error;
    return wantarray ? @result : $result[0];
}

# locate_projects(@terms)
# Fuzzy-matches projects under configured roots.
# Input: list of search term strings.
# Output: list of matched project directory paths.
sub locate_projects {
    my ( $self, @terms ) = @_;

    my @roots = grep { defined && -d } ( $self->workspace_roots, $self->project_roots );
    my @found;
    my %seen;

    for my $root (@roots) {
        opendir my $dh, $root or next;    # uncoverable branch true
        while ( my $entry = readdir $dh ) {
            next if $entry =~ /^\./;
            my $path = File::Spec->catdir( $root, $entry );
            next if !-d $path;

            my $ok = 1;
            for my $term (@terms) {
                next if !defined $term || $term eq '';
                if ( $entry !~ /\Q$term\E/i ) {
                    $ok = 0;
                    last;
                }
            }

            if ( $ok && !$seen{$path}++ ) {
                push @found, $path;
            }
        }
        closedir $dh;
    }

    return @found;
}

# locate_dirs_under($root, @terms)
# Recursively finds directories beneath one root whose relative path matches
# every supplied keyword.
# Input: search root directory path string plus zero or more keyword strings.
# Output: sorted list of matched directory path strings.
sub locate_dirs_under {
    my ( $self, $root, @terms ) = @_;
    return () if !defined $root || $root eq '' || !-d $root;

    my @wanted = map { $self->_compile_search_regex($_) } grep { defined && $_ ne '' } @terms;
    my $root_id = $self->_path_identity($root);
    my %seen;
    my @found;
    my @pending = ($root);

    while (@pending) {
        my $path = shift @pending;
        next if !-d $path;    # uncoverable branch true

        my $path_id = $self->_path_identity($path);
        next if $path_id eq '' || $seen{$path_id}++;    # uncoverable condition left

        my $relative = $path_id eq $root_id ? '.' : File::Spec->abs2rel( $path_id, $root_id );
        $relative = '.' if $relative eq '';    # uncoverable branch true
        $relative =~ s{\\}{/}g;
        $relative = $relative eq '.' ? '.' : './' . $relative;

        my $matches = 1;
        for my $term (@wanted) {
            if ( $relative !~ $term ) {
                $matches = 0;
                last;
            }
        }

        push @found, $path_id if $matches;

        opendir( my $dh, $path ) or next;
        while ( my $entry = readdir($dh) ) {
            next if $entry eq '.' || $entry eq '..';
            my $child = File::Spec->catdir( $path, $entry );
            next if !-d $child;
            push @pending, $child;
        }
        closedir($dh);
    }

    return sort @found;
}

# _compile_search_regex($pattern)
# Compiles one path-search token into the regex object used by recursive directory lookups.
# Input: one search token string.
# Output: compiled regex object, or dies when the token is not a valid regex.
sub _compile_search_regex {
    my ( $self, $pattern ) = @_;
    return if !defined $pattern || $pattern eq '';
    my $regex = eval { qr/$pattern/i };
    die "Invalid regex '$pattern': $@\n" if !$regex;
    return $regex;
}

# collector_dir($name)
# Returns the runtime directory for a named collector.
# Input: collector name string.
# Output: directory path string.
sub collector_dir {
    my ( $self, $name ) = @_;
    die 'Missing collector name' if !$name;
    return $self->_ensure_dir( File::Spec->catdir( $self->collectors_root, $name ) );
}

# indicator_dir($name)
# Returns the runtime directory for a named indicator.
# Input: indicator name string.
# Output: directory path string.
sub indicator_dir {
    my ( $self, $name ) = @_;
    die 'Missing indicator name' if !$name;
    return $self->_ensure_dir( File::Spec->catdir( $self->indicators_root, $name ) );
}

# ensure_dir($dir)
# Creates a directory if needed and applies runtime permission hardening.
# Input: directory path string.
# Output: directory path string.
sub ensure_dir {
    my ( $self, $dir ) = @_;
    return $self->_ensure_dir($dir);
}

# is_home_runtime_path($path)
# Checks whether one path lives under the home runtime tree.
# Input: file or directory path string.
# Output: boolean true when the path is inside ~/.developer-dashboard.
sub is_home_runtime_path {
    my ( $self, $path ) = @_;
    return 0 if !defined $path || $path eq '';
    return $self->_same_or_descendant_path( $path, $self->home_runtime_path ) ? 1 : 0;
}

# secure_dir_permissions($dir)
# Tightens one home-runtime directory chain to owner-only mode.
# Input: directory path string.
# Output: directory path string.
sub secure_dir_permissions {
    my ( $self, $dir ) = @_;
    return $dir if !$self->is_home_runtime_path($dir);

    my $home_runtime = $self->home_runtime_path;
    my $path = $home_runtime;
    if ( -d $path ) {
        chmod 0700, $path or die "Unable to chmod $path to 0700: $!";    # uncoverable branch true
    }
    return $dir if $dir eq $home_runtime;

    my $suffix = substr( $dir, length($home_runtime) );
    $suffix =~ s{^/}{};
    for my $part ( grep { $_ ne '' } File::Spec->splitdir($suffix) ) {
        $path = File::Spec->catdir( $path, $part );
        next if !-d $path;
        chmod 0700, $path or die "Unable to chmod $path to 0700: $!";    # uncoverable branch true
    }

    return $dir;
}

# secure_file_permissions($file, %args)
# Tightens one home-runtime file to owner-only mode, preserving owner execute
# bits when an executable file is expected.
# Input: file path string plus optional executable boolean.
# Output: file path string.
sub secure_file_permissions {
    my ( $self, $file, %args ) = @_;
    return $file if !defined $file || $file eq '';
    return $file if !$self->is_home_runtime_path($file) && !$self->_is_state_path($file);
    return $file if !-e $file;
    my $mode = $args{executable} ? 0700 : 0600;
    chmod $mode, $file or die sprintf 'Unable to chmod %s to %04o: %s', $file, $mode, $!;    # uncoverable branch true
    return $file;
}

# _is_state_path($path)
# Checks whether a file path is inside the configured runtime state area.
# Input: path string.
# Output: boolean true when the path is under state_base_root.
sub _is_state_path {
    my ( $self, $path ) = @_;
    return 0 if !defined $path || $path eq '';
    my $state_base = eval { $self->state_base_root };
    return 0 if !defined $state_base || $state_base eq '';
    return $self->_same_or_descendant_path( $path, $state_base );
}

# _ensure_dir($dir)
# Creates a directory if needed and returns it.
# Input: directory path string.
# Output: directory path string.
sub _ensure_dir {
    my ( $self, $dir ) = @_;
    if ( !-d $dir ) {
        if ( $self->is_home_runtime_path($dir) ) {
            make_path( $dir, { mode => 0700 } );
        }
        else {
            make_path($dir);
        }
    }
    $self->secure_dir_permissions($dir);
    return $dir;
}

# _ensure_state_dir($dir)
# Creates a state directory and applies owner-only hardening.
# Input: directory path string.
# Output: directory path string.
sub _ensure_state_dir {
    my ( $self, $dir ) = @_;
    if ( !-d $dir ) {
        make_path( $dir, { mode => 0700 } );
    }
    else {
        chmod 0700, $dir or die sprintf 'Unable to chmod %s to 0700: %s', $dir, $!;    # uncoverable branch true
    }
    return $dir;
}

# _expand_home($path)
# Expands leading home shorthands used by config and env values.
# Input: path string that may start with ~ or $HOME.
# Output: expanded path string.
sub _expand_home {
    my ( $self, $path ) = @_;
    return $path if !defined $path;
    $path =~ s/^\$HOME(?=\/|$)/$self->{home}/;
    $path =~ s/^~/$self->{home}/;
    return $path;
}

# _ancestor_runtime_layers()
# Discovers every existing .developer-dashboard layer between the current
# working directory and the configured home directory, excluding the home
# runtime root itself.
# Input: none.
# Output: ordered list of runtime root directory path strings from parentmost
# child layer to the deepest current layer.
sub _ancestor_runtime_layers {
    my ($self) = @_;
    my $cwd = $self->current_working_directory;
    return () if !defined $cwd || $cwd eq '';
    my $home = $self->home;
    my $home_runtime = $self->home_runtime_path;
    my $project_root = eval { $self->current_project_root } || '';
    my $stop_dir = '';
    if ( $self->_same_or_descendant_path( $cwd, $home ) ) {
        $stop_dir = $home;
    }
    elsif ( $project_root ne '' && $self->_same_or_descendant_path( $cwd, $project_root ) ) {
        $stop_dir = $project_root;
    }
    else {
        return ();
    }

    my @layers;
    my $dir = $cwd;
    while ($dir) {
        my $candidate = File::Spec->catdir( $dir, '.developer-dashboard' );
        my $visible_candidate = $self->_display_path($candidate);
        push @layers, $visible_candidate if -d $candidate && $self->_path_identity($candidate) ne $self->_path_identity($home_runtime);
        last if $self->_path_identity($dir) eq $self->_path_identity($stop_dir);
        my $parent = dirname($dir);
        last if $parent eq $dir;
        $dir = $parent;
    }
    return reverse @layers;
}

# _path_identity($path)
# Normalizes a path for identity and ancestry comparisons without requiring the
# caller to care about symlink aliases such as /var versus /private/var on macOS.
# Input: path string.
# Output: canonical existing path or a stable canonpath string.
sub _path_identity {
    my ( $self, $path ) = @_;
    return '' if !defined $path || $path eq '';
    my $resolved = eval { abs_path($path) };
    return $resolved if defined $resolved && $resolved ne '';    # uncoverable condition right
    return File::Spec->canonpath($path);
}

# _prefer_reference_style($path, $reference)
# Rewrites an equivalent filesystem path into the same textual style as $reference
# when both paths resolve to the same canonical identity.
# Input: filesystem path and reference path.
# Output: styled path string that preserves user-visible aliases when possible.
sub _prefer_reference_style {
    my ( $self, $path, $reference ) = @_;
    return $path if !defined $path || $path eq '';
    return $path if !defined $reference || $reference eq '';

    my $path_id = $self->_path_identity($path);
    my $ref_id  = $self->_path_identity($reference);
    return $path if $path_id eq '';    # uncoverable branch true

    my $prefix = $ref_id;
    $prefix .= '/' if $prefix !~ m{/$};
    return $path if index( $path_id, $prefix ) != 0;

    my $relative = substr( $path_id, length($prefix) );
    $relative =~ s{^/}{};
    return $reference if !$relative;

    my @segments = grep { $_ ne '..' } File::Spec->splitdir($relative);
    return File::Spec->catdir( $reference, @segments );
}

# _display_path($path)
# Returns a stable user-facing path string while keeping canonical identity
# handling separate for comparisons.
# Input: filesystem path string.
# Output: display-stable path string.
sub _display_path {
    my ( $self, $path ) = @_;
    return $path if !defined $path || $path eq '';

    for my $alias_prefix ( '/private/tmp', '/private/var' ) {
        next if index( $path, $alias_prefix ) != 0;
        my $short_prefix = substr( $alias_prefix, length('/private') );
        my $candidate = $short_prefix . substr( $path, length($alias_prefix) );
        next if $candidate eq '';    # uncoverable branch true
        next if $self->_path_identity($candidate) ne $self->_path_identity($path);
        return $candidate;
    }

    return $path;
}

# _same_or_descendant_path($path, $root)
# Checks whether one path is identical to or nested beneath another path after
# canonical normalization.
# Input: candidate path string and root path string.
# Output: boolean.
sub _same_or_descendant_path {
    my ( $self, $path, $root ) = @_;
    return 0 if !defined $path || $path eq '' || !defined $root || $root eq '';
    my $path_id = $self->_path_identity($path);
    my $root_id = $self->_path_identity($root);
    return 1 if $path_id eq $root_id;
    return index( $path_id, $root_id . '/' ) == 0 ? 1 : 0;
}

# _runtime_layers_from_env()
# Reads an explicit runtime-layer chain from the process environment when one
# is provided by the parent runtime process.
# Input: none.
# Output: ordered list of runtime root directory path strings from home to
# deepest layer.
sub _runtime_layers_from_env {
    my ($self) = @_;
    my $raw = $ENV{DEVELOPER_DASHBOARD_RUNTIME_LAYERS} || '';
    return () if $raw eq '';
    return grep { $_ ne '' } split /\n/, $raw;
}

# _memoize($key, $builder)
# Caches one derived path or layer value for the lifetime of the current
# registry object so repeated helper calls do not recompute cwd-driven state.
# Input: cache key string and builder code reference.
# Output: cached scalar or reference value.
sub _memoize {
    my ( $self, $key, $builder ) = @_;
    die "Missing memoization key\n" if !defined $key || $key eq '';
    die "Missing memoization builder\n" if ref($builder) ne 'CODE';
    return $self->{memo}{$key} if exists $self->{memo}{$key};
    $self->{memo}{$key} = $builder->();
    return $self->{memo}{$key};
}

1;

__END__

=head1 NAME

Developer::Dashboard::PathRegistry - logical directory registry

=head1 SYNOPSIS

  my $paths = Developer::Dashboard::PathRegistry->new(home => $ENV{HOME});
  my $root  = $paths->current_project_root;

=head1 DESCRIPTION

This module provides the central logical directory registry used across the
dashboard runtime, shell helpers, and background services.

=head1 METHODS

=head2 new

Construct the path registry.

=head2 resolve_dir, resolve_any, locate_projects, locate_dirs_under, current_project_root, project_root_for

Resolve and discover project-related directories.

=head2 current_working_directory, cwd

Report the effective working directory: the explicit constructor C<cwd> when one was supplied, otherwise a live in-process C<getcwd> lookup that follows later C<chdir> calls without forking an external C<pwd> process (undef when the directory is unavailable). C<cwd> is the public compatibility alias consumed by the file registry.

=head2 validated_path_segments

Package function that splits one untrusted relative path into its
separator-delimited segments and rejects any shape able to escape the directory
it will be joined onto: missing or empty paths, NUL or other control bytes,
backslash separators, absolute paths, Windows drive prefixes, and empty,
current-directory, or parent-directory segments. Returns the validated segment
list (segment count in scalar context) or an empty list (undef in scalar
context) on rejection. C<skill_layers> requires exactly one validated segment
for a skill name, and the skill dispatcher and web layer reuse this guard for
every skill-namespaced route, ajax, and static asset path.

=head2 runtime_root, cache_root, home_runtime_root, home_cache_root

Report the layered runtime directories. C<runtime_root> and C<cache_root> follow the deepest discovered layer, which is the write target for layered runtime state. C<home_runtime_root> and C<home_cache_root> stay pinned to the home layer, for per-user artifacts that a fixed external consumer reads from one well-known path - the generated shell-startup caches a login profile dot-sources are the motivating case, because a project-layer copy of those would be a cache no refresh ever rewrites.

=for comment FULL-POD-DOC START

=head1 PURPOSE

This module is the authoritative path model for the runtime. It discovers the layered runtime roots from home to the current project, resolves standard runtime directories, manages named path aliases, and performs project and directory searches such as the regex-based narrowing used by C<cdr>.

=head1 WHY IT EXISTS

It exists because C<DD-OOP-LAYERS> is a cross-runtime contract, not a convenience helper. One path registry has to own how home and project runtimes participate, which layer is writable, and how named paths and directory searches behave on top of that model.

=head1 WHEN TO USE

Use this file when changing layered runtime discovery, the writable runtime root, named alias behavior, project lookup, or directory-search semantics used by shell navigation helpers.

=head1 HOW TO USE

Construct it with the current home and cwd context, then ask it for runtime roots, named paths, or search results. Avoid rebuilding runtime path math elsewhere; other modules should consume this registry instead.

=head1 WHAT USES IT

It is used throughout the runtime by file, config, page, collector, prompt, shell-bootstrap, and CLI path logic, plus the tests that verify layered runtime behavior.

=head1 EXAMPLES

Example 1:

  perl -Ilib -MDeveloper::Dashboard::PathRegistry -e 1

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

Example 2:

  prove -lv t/07-core-units.t t/21-refactor-coverage.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
