package Pcore::Core::CLI;

use Pcore -class;
use Pcore::Util::Scalar qw[is_ref is_plain_arrayref];
use Getopt::Long qw[];
use Pcore::Core::CLI::Opt;
use Pcore::Core::CLI::Arg;
use Config;

has class    => ( required => 1 );
has cmd_path => ( sub { [] } );      # array of used cli commands

has spec       => ( is => 'lazy', init_arg => undef );    # HashRef
has cmd        => ( is => 'lazy', init_arg => undef );    # ArrayRef
has opt        => ( is => 'lazy', init_arg => undef );    # HashRef
has arg        => ( is => 'lazy', init_arg => undef );    # ArrayRef
has is_cmd     => ( is => 'lazy', init_arg => undef );
has _cmd_index => ( is => 'lazy', init_arg => undef );    # HashRef

sub _build_spec ($self) { return $self->_get_class_spec }

sub _build_cmd ($self) {
    my $cmd = [];

    if ( my $cli_cmd = $self->spec->{cmd} ) {
        my @classes;

        for my $cli_cmd_class ( $cli_cmd->@* ) {
            if ( substr( $cli_cmd_class, -2, 2 ) eq q[::] ) {
                my $ns = $cli_cmd_class;

                my $ns_path = $ns =~ s[::][/]smgr;

                for (@INC) {
                    next if ref;

                    my $path = $_ . q[/] . $ns_path;

                    next if !-d $path;

                    for my $fn ( P->file->read_dir( $path, full_path => 0 )->@* ) {
                        if ( $fn =~ /\A(.+)[.]pm\z/sm && -f "$path/$fn" ) {
                            push @classes, $ns . $1;
                        }
                    }
                }
            }
            else {
                push @classes, $cli_cmd_class;
            }
        }

        my $index;

        for my $class (@classes) {
            next if $index->{$class};

            $index->{$class} = 1;

            $class = P->class->load($class);

            if ( $class->isa('Pcore::Core::CLI::Cmd') ) {
                push $cmd->@*, $class;
            }
        }
    }

    return $cmd;
}

sub _build_opt ($self) {
    my $opt = {};

    my $index = {
        help    => undef,
        h       => undef,
        q[?]    => undef,
        version => undef,
    };

    if ( my $cli_opt = $self->spec->{opt} ) {
        for my $name ( keys $cli_opt->%* ) {
            die qq[Option "$name" is duplicated] if exists $index->{$name};

            $opt->{$name} = Pcore::Core::CLI::Opt->new( { $cli_opt->{$name}->%*, name => $name } );

            $index->{$name} = 1;

            if ( $opt->{$name}->short ) {
                die qq[Short name "@{[$opt->{$name}->short]}" for option "$name" is duplicated] if exists $index->{ $opt->{$name}->short };

                $index->{ $opt->{$name}->short } = 1;
            }
        }
    }

    return $opt;
}

sub _build_arg ($self) {
    my $args = [];

    my $index = {};

    my $next_arg = 0;    # 0 - any, 1 - min = 0, 2 - no arg

    if ( my $cli_arg = $self->spec->{arg} ) {
        for ( my $i = 0; $i <= $cli_arg->$#*; $i += 2 ) {
            die q[Can't have other arguments after slurpy argument] if $next_arg == 2;

            $cli_arg->[ $i + 1 ]->{name} = $cli_arg->[$i];

            my $arg = Pcore::Core::CLI::Arg->new( $cli_arg->[ $i + 1 ] );

            die q[Can't have required argument after not mandatory argument] if $next_arg == 1 && $arg->min != 0;

            die qq[Argument "@{[$arg->{name}]}" is duplicated] if exists $index->{ $arg->{name} };

            if ( !$arg->max ) {    # slurpy arg
                $next_arg = 2;
            }
            elsif ( $arg->min == 0 ) {
                $next_arg = 1;
            }

            push $args->@*, $arg;

            $index->{ $arg->{name} } = 1;
        }
    }

    return $args;
}

sub _build__cmd_index ($self) {
    my $index = {};

    for my $class ( $self->cmd->@* ) {
        for my $cmd ( $self->_get_class_cmd($class)->@* ) {
            die qq[Command "$cmd" is duplicated] if exists $index->{$cmd};

            $index->{$cmd} = $class;
        }
    }

    return $index;
}

sub _build_is_cmd ($self) { return $self->_cmd_index->%* ? 1 : 0 }

sub run ( $self, $argv ) {

    # redirect, if class is defined
    if ( $self->spec->{class} ) {
        require $self->spec->{class} =~ s[::][/]smgr . '.pm';

        return __PACKAGE__->new( { class => $self->spec->{class} } )->run($argv);
    }

    # make a copy
    my @argv = $argv ? $argv->@* : ();

    if ( $self->is_cmd ) {
        return $self->_parse_cmd( \@argv );
    }
    else {
        return $self->_parse_opt( \@argv );
    }
}

sub _parse_cmd ( $self, $argv ) {
    my $res = {
        cmd  => undef,
        opt  => {},
        rest => undef,
    };

    my $parser = Getopt::Long::Parser->new(
        config => [    #
            'no_auto_abbrev',
            'no_getopt_compat',
            'gnu_compat',
            'no_require_order',
            'permute',
            'bundling',
            'no_ignore_case',
            'pass_through',
        ]
    );

    $parser->getoptionsfromarray(
        $argv,
        $res->{opt},
        'help|h|?',
        'version',
        '<>' => sub ($arg) {
            if ( !$res->{cmd} && substr( $arg, 0, 1 ) ne q[-] ) {
                $res->{cmd} = $arg;
            }
            else {
                push $res->{rest}->@*, $arg;
            }

            return;
        }
    );

    push $res->{rest}->@*, $argv->@* if defined $argv && $argv->@*;

    if ( $res->{opt}->{version} ) {
        return $self->help_version;
    }
    elsif ( !defined $res->{cmd} ) {
        if ( $res->{opt}->{help} ) {
            return $self->help;
        }
        else {
            return $self->help_usage;
        }
    }
    else {
        my $possible_commands = [];

        my @index = keys $self->_cmd_index->%*;

        for my $cmd_name (@index) {
            push $possible_commands->@*, $cmd_name if index( $cmd_name, $res->{cmd} ) == 0;
        }

        if ( !$possible_commands->@* ) {
            return $self->help_usage( [qq[command "$res->{cmd}" is unknown]] );
        }
        elsif ( $possible_commands->@* > 1 ) {
            return $self->help_error( qq[command "$res->{cmd}" is ambiguous:\n  ] . join $SPACE, $possible_commands->@* );
        }
        else {
            unshift $res->{rest}->@*, '--help' if $res->{opt}->{help};

            my $class = $self->_cmd_index->{ $possible_commands->[0] };

            push $self->{cmd_path}->@*, $self->_get_class_cmd($class)->[0];

            return __PACKAGE__->new( { class => $class, cmd_path => $self->{cmd_path} } )->run( $res->{rest} );
        }
    }
}

sub _parse_opt ( $self, $argv ) {
    my $res = {
        error => undef,
        opt   => {},
        arg   => {},
        rest  => undef,
    };

    # build cli spec for Getopt::Long
    my $cli_spec = [];

    for my $opt ( values $self->opt->%* ) {
        push $cli_spec->@*, $opt->getopt_spec;
    }

    my $parser = Getopt::Long::Parser->new(
        config => [    #
            'auto_abbrev',
            'no_getopt_compat',    # do not allow + to start options
            'gnu_compat',
            'no_require_order',
            'permute',
            'bundling',
            'no_ignore_case',
            'no_pass_through',
        ]
    );

    my $parsed_args = [];

    {
        no warnings qw[redefine];

        local $SIG{__WARN__} = sub {
            push $res->{error}->@*, join $EMPTY, @_;

            $res->{error}->[-1] =~ s/\n\z//sm;

            return;
        };

        $parser->getoptionsfromarray(
            $argv,
            $res->{opt},
            $cli_spec->@*,
            'version',
            'help|h|?',
            '<>' => sub ($arg) {
                push $parsed_args->@*, $arg;

                return;
            }
        );

        push $res->{rest}->@*, $argv->@* if defined $argv && $argv->@*;
    }

    if ( $res->{opt}->{version} ) {
        return $self->help_version;
    }
    elsif ( $res->{opt}->{help} ) {
        return $self->help;
    }
    elsif ( $res->{error} ) {
        return $self->help_usage( $res->{error} );
    }

    # validate options
    for my $opt ( values $self->opt->%* ) {
        if ( my $error_msg = $opt->validate( $res->{opt} ) ) {
            return $self->help_usage( [$error_msg] );
        }
    }

    # parse and validate args
    for my $arg ( $self->arg->@* ) {
        if ( my $error_msg = $arg->parse( $parsed_args, $res->{arg} ) ) {
            return $self->help_usage( [$error_msg] );
        }
    }

    return $self->help_usage( [q[unexpected arguments]] ) if $parsed_args->@*;

    # validate cli
    my $class = $self->{class};

    if ( $class->can('CLI_VALIDATE') && defined( my $error_msg = $class->CLI_VALIDATE( $res->{opt}, $res->{arg}, $res->{rest} ) ) ) {
        return $self->help_error($error_msg);
    }

    # store results globally
    $ENV->{cli} = $res;

    # run
    if ( $class->can('CLI_RUN') ) {
        return $class->CLI_RUN( $res->{opt}, $res->{arg}, $res->{rest} );
    }
    else {
        return $res;
    }
}

sub _get_class_spec ( $self, $class = undef ) {
    $class //= $self->{class};

    if ( $class->can('CLI') && ( my $spec = $class->CLI ) ) {
        if ( !is_ref $spec ) {
            $spec = { class => $spec };
        }
        elsif ( is_plain_arrayref $spec ) {
            $spec = { cmd => $spec };
        }
        else {
            $spec->{cmd} = [ $spec->{cmd} ] if $spec->{cmd} && !is_ref $spec->{cmd};

            $spec->{name} = [ $spec->{name} ] if $spec->{name} && !is_ref $spec->{name};
        }

        return $spec;
    }
    else {
        return {};
    }
}

sub _get_class_cmd ( $self, $class = undef ) {
    my $spec = $class ? $self->_get_class_spec($class) : $self->spec;

    if ( $spec->{name} ) {
        return $spec->{name};
    }
    else {
        $class //= $self->{class};

        return [ lc $class =~ s/\A.*:://smr ];
    }
}

# HELP
sub _help_class_abstract ( $self, $class = undef ) {
    my $spec = $class ? $self->_get_class_spec($class) : $self->spec;

    return $spec->{abstract} // $EMPTY;
}

sub _help_usage_string ($self) {
    my $usage = join $SPACE, P->path( $ENV->{SCRIPT_NAME} )->{filename}, $self->{cmd_path}->@*;

    if ( $self->is_cmd ) {
        $usage .= ' [COMMAND] [OPTION]...';
    }
    else {
        $usage .= ' [OPTION]...' if $self->opt->%*;

        if ( $self->arg->@* ) {
            my @args;

            for my $arg ( $self->arg->@* ) {
                push @args, $arg->help_spec;
            }

            $usage .= $SPACE . join $SPACE, @args;
        }
    }

    return $usage;
}

sub _help_alias ($self) {
    my $cmd = $self->_get_class_cmd;

    shift $cmd->@*;

    if ( $cmd->@* ) {
        return 'aliases: ' . join $SPACE, sort $cmd->@*;
    }
    else {
        return $EMPTY;
    }
}

sub _help ($self) {
    my $help = $self->spec->{help} // $EMPTY;

    if ($help) {
        $help =~ s/^/    /smg;

        $help =~ s/\n+\z//sm;
    }

    return $help;
}

sub _help_usage ($self) {
    my $help;

    my $list = {};

    if ( $self->is_cmd ) {
        $help = "list of commands:\n\n";

        for my $class ( $self->cmd->@* ) {
            $list->{ $self->_get_class_cmd($class)->[0] } = [ $self->_get_class_cmd($class)->[0], $self->_help_class_abstract($class) ];
        }
    }
    else {
        $help = "options ([+] - can be repeated, [!] - is required):\n\n";

        for my $opt ( values $self->opt->%* ) {
            $list->{ $opt->{name} } = [ $opt->help_spec, $opt->{desc} // $EMPTY ];
        }
    }

    return $EMPTY if !$list->%*;

    my $max_key_len = 10;

    for ( values $list->%* ) {
        $max_key_len = length $_->[0] if length $_->[0] > $max_key_len;

        # remove \n from desc
        $_->[1] =~ s/\n+\z//smg;
    }

    my $desc_indent = "\n    " . ( $SPACE x $max_key_len );

    $help .= join "\n", map { sprintf( " %-${max_key_len}s   ", $list->{$_}->[0] ) . $list->{$_}->[1] =~ s/\n/$desc_indent/smgr } sort keys $list->%*;

    return $help // $EMPTY;
}

sub _help_footer ($self) { return '(global options: --help, -h, -?, --version)' }

sub help ($self) {
    print $self->_help_usage_string, "\n\n";

    if ( my $alias = $self->_help_alias ) {
        say $alias, "\n";
    }

    if ( my $abstract = $self->_help_class_abstract ) {
        print "$abstract\n\n";
    }

    if ( my $help = $self->_help ) {
        print "$help\n\n";
    }

    if ( my $help_usage = $self->_help_usage ) {
        print "$help_usage\n\n";
    }

    say $self->_help_footer, "\n";

    exit 2;
}

sub help_usage ( $self, $invalid_options = undef ) {
    if ($invalid_options) {
        for ( $invalid_options->@* ) {
            say;
        }

        print "\n";
    }

    say $self->_help_usage_string, "\n";

    if ( my $abstract = $self->_help_class_abstract ) {
        print "$abstract\n\n";
    }

    if ( my $help_usage = $self->_help_usage ) {
        print "$help_usage\n\n";
    }

    say $self->_help_footer, "\n";

    exit 2;
}

sub help_version ($self) {

    # dist info
    if ( $ENV->dist ) {
        say $ENV->dist->version_string;
    }
    else {
        say join $SPACE, $ENV->{SCRIPT_NAME}, ( $main::VERSION ? version->new($main::VERSION)->normal : () );
    }

    # Pcore info
    say $ENV->{pcore}->version_string if !$ENV->dist || $ENV->dist->name ne $ENV->{pcore}->name;

    # perl info
    say 'Perl ' . $^V->normal . " $Config{archname}";

    # PAR info
    say join "\n", "Image path: $ENV{PAR_PROGNAME}", "Temp dir: $ENV{PAR_TEMP}" if $ENV->{is_par};

    exit 2;
}

sub help_error ( $self, $msg ) {
    print "$msg\n\n" if defined $msg;

    exit 2;
}

1;
## -----SOURCE FILTER LOG BEGIN-----
##
## PerlCritic profile "pcore-script" policy violations:
## +------+----------------------+----------------------------------------------------------------------------------------------------------------+
## | Sev. | Lines                | Policy                                                                                                         |
## |======+======================+================================================================================================================|
## |    3 | 42                   | ControlStructures::ProhibitDeepNests - Code structure is deeply nested                                         |
## |------+----------------------+----------------------------------------------------------------------------------------------------------------|
## |    3 | 488, 516             | NamingConventions::ProhibitAmbiguousNames - Ambiguously named variable "abstract"                              |
## |------+----------------------+----------------------------------------------------------------------------------------------------------------|
## |    2 | 108                  | ControlStructures::ProhibitCStyleForLoops - C-style "for" loop used                                            |
## +------+----------------------+----------------------------------------------------------------------------------------------------------------+
##
## -----SOURCE FILTER LOG END-----
__END__
=pod

=encoding utf8

=head1 NAME

Pcore::Core::CLI

=head1 SYNOPSIS

    # redirect CLI processing
    sub CLI ($self) {
        return 'Other::Class';
    }

    # CLI commands hub
    sub CLI {
        return ['Cmd1', 'Cmd2', 'Cmd::Modules::' ];
    }

    # or
    sub CLI {
        return {
            abstract => 'Abstract description',
            help     => <<'HELP',
    Full CLI help
    HELP
            cmd      => ['Cmd1', 'Cmd2', 'Cmd::Modules::' ],
        };
    }

    # CLI command class
    extends qw[Pcore::Core::CLI::Cmd];

    sub CLI ($self) {
        return {
            name     => 'command',
            abstract => 'abstract desc',
            help     => undef,
            opt      => {},
            arg      => {},
        };
    }

    sub CLI_VALIDATE ( $self, $opt, $arg, $rest ) {
        return;
    }

    sub CLI_RUN ( $self, $opt, $arg, $rest ) {
        return;
    }

=head1 DESCRIPTION

CLI class can be either a CLI "commands hub" or "command". Command hub - only keep other CLI commands together, it doesn't do anything else. CLI command must be a instance of Pcore::Core::CLI::Cmd role.

=head1 METHODS

=head2 CLI ($self)

Return CLI specification as Str, ArrayRef of HashRef. Str - name of class to redirect CLI processor to. ArrayRef - list of CLI commands classes or namespaces. HashRef - full CLI specification, where supported keys are:

=over

=item * cmd - CLI commands classes names or namespace. Namespace should be specified with '::' at the end, eg.: 'My::CLI::Packages::'. cmd can be Str or ArrayRef[Str];

=item * abstract - short description;

=item * help - full help, can be multiline string;

=item * name - CLI command name, can be a Str or ArrayRef[Str], if command has aliases. If command name is not specified - if will be parsed from the last segment of the class name;

=item * opt - HashRef, options specification;

=item * arg - ArrayRef, arguments specification;

=back

=head2 CLI_VALIDATE ( $self, $opt, $arg, $rest )

Should validate parsed CLI data and return Str in case of error or undef.

=head2 CLI_RUN ( $self, $opt, $arg, $rest )

=head1 SEE ALSO

=cut
