package Developer::Dashboard::DockerCompose;
$Developer::Dashboard::DockerCompose::VERSION = '1.22.1';
use strict;
use warnings;

use Capture::Tiny qw(capture);
use Cwd qw(cwd);
use File::Spec;

use Developer::Dashboard::JSON qw(json_encode);

# new(%args)
# Constructs the docker compose resolver and launcher.
# Input: config and paths objects.
# Output: Developer::Dashboard::DockerCompose object.
sub new {
    my ( $class, %args ) = @_;
    my $config = $args{config} || die 'Missing config';
    my $paths  = $args{paths}  || die 'Missing path registry';
    return bless {
        config => $config,
        paths  => $paths,
    }, $class;
}

# resolve(%args)
# Resolves the effective docker compose context and overlay stack.
# Input: optional project_root, addons, modes, services, and compose args.
# Output: hash reference describing files, env, layers, precedence, and final command.
sub resolve {
    my ( $self, %args ) = @_;
    my $project_root = $args{project_root} || $self->{paths}->current_project_root || cwd();
    my $docker_cfg  = $self->{config}->docker_config;
    my $docker_root = $self->_docker_config_root;
    my @passthrough = @{ $args{args} || [] };
    my @compose_files = ();
    my @layers;

    my @base = $self->_discover_base_files($project_root);
    push @compose_files, @base;
    push @layers, { name => 'base', files => [@base] };

    my @project_overlays = ( @{ $docker_cfg->{files} || [] }, @{ $docker_cfg->{project_overlays} || [] } );
    push @compose_files, @project_overlays;
    push @layers, { name => 'project', files => [@project_overlays] } if @project_overlays;

    my @addons = @{ $args{addons} || [] };
    my @modes  = @{ $args{modes}  || [] };
    my @services = @{ $args{services} || [] };

    my %addon_map = (
        %{ $docker_cfg->{addons} || {} },
    );
    my %mode_map = (
        %{ $docker_cfg->{modes} || {} },
    );
    my %service_map = (
        %{ $docker_cfg->{services} || {} },
    );
    my @inferred_services = $self->_infer_services_from_args(
        args         => \@passthrough,
        project_root => $project_root,
        service_map  => \%service_map,
    );
    my %service_seen;
    @services = grep { !$service_seen{$_}++ } ( @services, @inferred_services );
    if ( !@services ) {
        my @auto_services = $self->_discover_enabled_services(
            project_root => $project_root,
            service_map  => \%service_map,
        );
        @services = grep { !$service_seen{$_}++ } @auto_services;
    }

    my @service_files;
    for my $service (@services) {
        my $def = $service_map{$service};
        next if ref($def) ne 'HASH';
        push @service_files, @{ $def->{files} || [] } if ref( $def->{files} ) eq 'ARRAY';
    }
    for my $service (@services) {
        push @service_files, $self->_discover_service_files(
            service      => $service,
            project_root => $project_root,
            modes        => \@modes,
        );
    }
    push @compose_files, @service_files;
    push @layers, { name => 'service', files => [@service_files] } if @service_files;

    my @addon_files;
    for my $addon (@addons) {
        my $def = $addon_map{$addon};
        next if ref($def) ne 'HASH';
        push @addon_files, @{ $def->{files} || [] } if ref( $def->{files} ) eq 'ARRAY';
        push @modes, @{ $def->{modes} || [] } if ref( $def->{modes} ) eq 'ARRAY';
    }
    push @compose_files, @addon_files;
    push @layers, { name => 'addon', files => [@addon_files] } if @addon_files;

    my @mode_files;
    for my $mode (@modes) {
        my $def = $mode_map{$mode};
        next if ref($def) ne 'HASH';
        push @mode_files, @{ $def->{files} || [] } if ref( $def->{files} ) eq 'ARRAY';
    }
    push @compose_files, @mode_files;
    push @layers, { name => 'mode', files => [@mode_files] } if @mode_files;

    my @files;
    my %seen;
    for my $file (@compose_files) {
        next if !defined $file || $file eq '';
        $file = $self->_expand_env_path($file);
        $file = File::Spec->catfile( $project_root, $file ) if !File::Spec->file_name_is_absolute($file);
        next if $seen{$file}++;
        push @files, $file if -f $file;
    }

    my %env = (
        %{ $docker_cfg->{env} || {} },
        DDDC => $docker_root,
    );
    for my $addon (@addons) {
        my $def = $addon_map{$addon};
        next if ref($def) ne 'HASH' || ref( $def->{env} ) ne 'HASH';
        @env{ keys %{ $def->{env} } } = values %{ $def->{env} };
    }
    for my $mode (@modes) {
        my $def = $mode_map{$mode};
        next if ref($def) ne 'HASH' || ref( $def->{env} ) ne 'HASH';
        @env{ keys %{ $def->{env} } } = values %{ $def->{env} };
    }

    my @command = ('docker', 'compose');
    for my $file (@files) {
        push @command, '-f', $file;
    }
    push @command, @passthrough;

    return {
        project_root => $project_root,
        addons       => \@addons,
        modes        => \@modes,
        services     => \@services,
        files        => \@files,
        env          => \%env,
        command      => \@command,
        layers       => \@layers,
        precedence   => [ qw(base project service addon mode) ],
    };
}

# _expand_env_path($path)
# Expands ${VAR} and $VAR environment placeholders in configured compose file paths.
# Input: file path string that may contain environment variable placeholders.
# Output: expanded file path string.
sub _expand_env_path {
    my ( $self, $path ) = @_;
    return $path if !defined $path || $path eq '';

    $path =~ s/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/defined $ENV{$1} ? $ENV{$1} : ''/ge;
    $path =~ s/\$([A-Za-z_][A-Za-z0-9_]*)/defined $ENV{$1} ? $ENV{$1} : ''/ge;

    return $path;
}

# _docker_config_root()
# Returns the dashboard docker configuration root used for isolated service folders.
# Input: none.
# Output: absolute directory path string.
sub _docker_config_root {
    my ($self) = @_;
    return File::Spec->catdir( $self->{paths}->config_root, 'docker' );
}

# _home_docker_config_root()
# Returns the home-backed docker configuration root used as the fallback for isolated service folders.
# Input: none.
# Output: absolute directory path string.
sub _home_docker_config_root {
    my ($self) = @_;
    return File::Spec->catdir( $self->{paths}->home_runtime_root, 'config', 'docker' );
}

# _discover_service_files(%args)
# Discovers the preferred old-style isolated compose file for a named service from repo-local and global docker config roots.
# Input: service name and optional project_root.
# Output: ordered list of discovered compose file paths, preferring development.compose.yml over compose.yml per folder.
sub _discover_service_files {
    my ( $self, %args ) = @_;
    my $service      = $args{service} || return;
    my $project_root = $args{project_root} || cwd();
    return if $self->_service_folder_is_disabled(
        project_root => $project_root,
        service      => $service,
    );

    my @roots = $self->_service_lookup_roots(
        project_root => $project_root,
        service      => $service,
    );

    my @files;
    my %seen;
    for my $root (@roots) {
        next if !defined $root || $root eq '';
        my $service_root = File::Spec->catdir( $root, $service );
        next if !-d $service_root;

        my $development = File::Spec->catfile( $service_root, 'development.compose.yml' );
        if ( -f $development ) {
            push @files, $development if !$seen{$development}++;
            next;
        }

        my $compose = File::Spec->catfile( $service_root, 'compose.yml' );
        push @files, $compose if -f $compose && !$seen{$compose}++;
    }

    return @files;
}

# _discover_enabled_services(%args)
# Lists isolated services that should be auto-loaded when no service is selected in the command.
# Input: project_root and optional service_map hash reference.
# Output: ordered list of auto-loaded service name strings.
sub _discover_enabled_services {
    my ( $self, %args ) = @_;
    my @services = $self->_discover_service_names(%args);
    return grep {
        !$self->_service_folder_is_disabled(
            project_root => $args{project_root},
            service      => $_,
        )
    } @services;
}

# _discover_service_names(%args)
# Lists known compose service names from config maps and isolated service folders.
# Input: project_root and optional service_map hash reference.
# Output: sorted list of service name strings.
sub _discover_service_names {
    my ( $self, %args ) = @_;
    my $project_root = $args{project_root} || cwd();
    my $service_map  = $args{service_map} || {};
    my %names = map { $_ => 1 } grep { defined && $_ ne '' } keys %{$service_map};

    for my $root (
        File::Spec->catdir( $project_root, '.developer-dashboard', 'docker' ),
        $self->_home_docker_config_root,
      )
    {
        next if !-d $root;
        opendir my $dh, $root or next;
        while ( my $entry = readdir $dh ) {
            next if $entry eq '.' || $entry eq '..';
            next if !-d File::Spec->catdir( $root, $entry );
            $names{$entry} = 1;
        }
        closedir $dh;
    }

    return sort keys %names;
}

# _service_folder_is_disabled(%args)
# Checks whether an isolated service folder opts out of automatic compose inclusion.
# Input: service name and optional project_root.
# Output: boolean true when the service folder contains a disabled.yml marker.
sub _service_folder_is_disabled {
    my ( $self, %args ) = @_;
    my $service      = $args{service} || return 0;
    my $project_root = $args{project_root} || cwd();
    my @roots = $self->_service_lookup_roots(
        project_root => $project_root,
        service      => $service,
    );
    return 0 if !@roots;
    my $service_root = File::Spec->catdir( $roots[0], $service );
    return 1 if -f File::Spec->catfile( $service_root, 'disabled.yml' );

    return 0;
}

# _service_lookup_roots(%args)
# Returns the docker roots that should be searched for one isolated service with project-local precedence over home runtime fallbacks.
# Input: service name and optional project_root.
# Output: ordered list of docker root directory path strings.
sub _service_lookup_roots {
    my ( $self, %args ) = @_;
    my $service      = $args{service} || return;
    my $project_root = $args{project_root} || cwd();
    my $project_docker_root = File::Spec->catdir( $project_root, '.developer-dashboard', 'docker' );
    my $project_service_root = File::Spec->catdir( $project_docker_root, $service );
    return ($project_docker_root) if -d $project_service_root;
    return ( $self->_home_docker_config_root );
}

# _infer_services_from_args(%args)
# Infers service names from passthrough docker compose arguments before the real command is executed.
# Input: args array reference, project_root, and optional service_map hash reference.
# Output: ordered list of inferred service name strings.
sub _infer_services_from_args {
    my ( $self, %args ) = @_;
    my $argv         = $args{args} || [];
    my $project_root = $args{project_root} || cwd();
    my $service_map  = $args{service_map} || {};
    my %known = map { $_ => 1 } $self->_discover_service_names(
        project_root => $project_root,
        service_map  => $service_map,
    );

    my @services;
    my %seen;
    for my $arg ( @{$argv} ) {
        next if !defined $arg || $arg eq '';
        next if $arg =~ /^-/;
        next if !$known{$arg};
        next if $seen{$arg}++;
        push @services, $arg;
    }

    return @services;
}

# run(%args)
# Executes the resolved docker compose command or returns dry-run data.
# Input: same resolution arguments plus optional dry_run flag.
# Output: resolution hash reference with stdout/stderr/exit_code when executed.
sub run {
    my ( $self, %args ) = @_;
    my $resolved = $self->resolve(%args);
    return $resolved if $args{dry_run};

    my $old = cwd();
    chdir $resolved->{project_root} or die "Unable to chdir to $resolved->{project_root}: $!";
    local @ENV{ keys %{ $resolved->{env} } } = values %{ $resolved->{env} } if %{ $resolved->{env} };
    my ( $stdout, $stderr, $exit_code ) = capture {
        system @{ $resolved->{command} };
        return $? >> 8;
    };
    chdir $old or die "Unable to restore cwd to $old: $!";

    return {
        %$resolved,
        stdout    => $stdout,
        stderr    => $stderr,
        exit_code => $exit_code,
    };
}

# _discover_base_files($root)
# Finds standard base compose files under a project root.
# Input: project root directory path.
# Output: ordered list of existing compose file paths.
sub _discover_base_files {
    my ( $self, $root ) = @_;
    my @candidates = qw(compose.yml compose.yaml docker-compose.yml docker-compose.yaml);
    return grep { -f $_ } map { File::Spec->catfile( $root, $_ ) } @candidates;
}

1;

__END__

=head1 NAME

Developer::Dashboard::DockerCompose - compose resolver and launcher

=head1 SYNOPSIS

  my $docker = Developer::Dashboard::DockerCompose->new(
      config  => $config,
      paths   => $paths,
      plugins => $plugins,
  );

=head1 DESCRIPTION

This module resolves layered docker compose inputs into a final transparent
docker compose command line and can optionally execute it.

=head1 METHODS

=head2 new, resolve, run

Construct, resolve, and optionally execute compose operations.

=cut
