package PVE::CLI::pvesh;

use strict;
use warnings;
use HTTP::Status qw(:constants :is status_message);
use String::ShellQuote;
use PVE::JSONSchema qw(get_standard_option);
use PVE::SafeSyslog;
use PVE::Cluster;
use PVE::INotify;
use PVE::RPCEnvironment;
use PVE::RESTHandler;
use PVE::CLIFormatter;
use PVE::CLIHandler;
use PVE::API2Tools;
use PVE::API2;
use JSON;
use IO::Uncompress::Gunzip qw(gunzip);

use base qw(PVE::CLIHandler);

my $disable_proxy = 0;
my $opt_nooutput = 0;

# compatibility code
my $optmatch;
do {
    $optmatch = 0;
    if ($ARGV[0]) {
        if ($ARGV[0] eq '--noproxy') {
            shift @ARGV;
            $disable_proxy = 1;
            $optmatch = 1;
        } elsif ($ARGV[0] eq '--nooutput') {
            # we use this when starting task in CLI (suppress printing upid)
            # for example 'pvesh --nooutput create /nodes/localhost/stopall'
            shift @ARGV;
            $opt_nooutput = 1;
            $optmatch = 1;
        }
    }
} while ($optmatch);

sub setup_environment {
    PVE::RPCEnvironment->setup_default_cli_env();
}

sub complete_api_path {
    my ($text) = @_;

    my ($dir, undef, $rest) = $text =~ m|^(.*/)?(([^/]*))?$|;

    my $path = $dir // ''; # copy

    $path =~ s|/+|/|g;
    $path =~ s|^\/||;
    $path =~ s|\/$||;

    my $res = [];

    my $di = dir_info($path);
    if (my $children = $di->{children}) {
        foreach my $c (@$children) {
            if ($c =~ /^\Q$rest/) {
                my $new = $dir ? "$dir$c" : $c;
                push @$res, $new;
            }
        }
    }

    if (scalar(@$res) == 1) {
        return [$res->[0], "$res->[0]/"];
    }

    return $res;
}

my $method_map = {
    create => 'POST',
    set => 'PUT',
    get => 'GET',
    delete => 'DELETE',
};

sub check_proxyto {
    my ($info, $uri_param, $params) = @_;

    my $rpcenv = PVE::RPCEnvironment->get();

    my $all_params = { %$uri_param, %$params };

    if ($info->{proxyto} || $info->{proxyto_callback}) {
        my $node = PVE::API2Tools::resolve_proxyto(
            $rpcenv,
            $info->{proxyto_callback},
            $info->{proxyto},
            $all_params,
        );

        if ($node ne 'localhost' && ($node ne PVE::INotify::nodename())) {
            die "proxy loop detected - aborting\n" if $disable_proxy;
            my $remip = PVE::Cluster::remote_node_ip($node);
            return ($node, $remip);
        }
    }

    return undef;
}

sub proxy_handler {
    my ($node, $remip, $path, $cmd, $param) = @_;

    my $args = [];
    foreach my $key (keys %$param) {
        next if $key eq 'quiet' || $key eq 'output-format'; # just to  be sure
        if (ref($param->{$key}) eq 'ARRAY') {
            push @$args, "--$key", $_ for $param->{$key}->@*;
        } else {
            push @$args, "--$key", $_ for split(/\0/, $param->{$key});
        }
    }

    my $ssh_tunnel_cmd = PVE::SSHInfo::ssh_info_to_command({ ip => $remip, name => $node });

    my @pvesh_cmd = ('pvesh', '--noproxy', $cmd, $path, '--output-format', 'json');
    if (scalar(@$args)) {
        my $cmdargs = [String::ShellQuote::shell_quote(@$args)];
        push @pvesh_cmd, @$cmdargs;
    }

    my $res = '';
    PVE::Tools::run_command(
        [$ssh_tunnel_cmd->@*, '--', @pvesh_cmd],
        errmsg => "proxy handler failed",
        outfunc => sub { $res .= shift },
    );

    my $decoded_json = eval { decode_json($res) };
    if ($@) {
        return $res; # do not error, '' (null) is valid too
    }
    return $decoded_json;
}

sub extract_children {
    my ($lnk, $data) = @_;

    my $res = [];

    return $res if !($lnk && $data);

    my $href = $lnk->{href};
    if ($href =~ m/^\{(\S+)\}$/) {
        my $prop = $1;

        foreach my $elem (sort { $a->{$prop} cmp $b->{$prop} } @$data) {
            next if !ref($elem);
            my $value = $elem->{$prop};
            push @$res, $value;
        }
    }

    return $res;
}

sub dir_info {
    my ($path) = @_;

    my $res = { path => $path };
    my $uri_param = {};
    my ($handler, $info, $pm) = PVE::API2->find_handler('GET', $path, $uri_param);
    if ($handler && $info) {
        eval {
            my $data = $handler->handle($info, $uri_param);
            my $lnk = PVE::JSONSchema::method_get_child_link($info);
            $res->{children} = extract_children($lnk, $data);
        }; # ignore errors ?
    }
    return $res;
}

sub resource_cap {
    my ($path) = @_;

    my $res = '';

    my ($handler, $info) = PVE::API2->find_handler('GET', $path);
    if (!($handler && $info)) {
        $res .= '--';
    } else {
        if (PVE::JSONSchema::method_get_child_link($info)) {
            $res .= 'Dr';
        } else {
            $res .= '-r';
        }
    }

    ($handler, $info) = PVE::API2->find_handler('PUT', $path);
    if (!($handler && $info)) {
        $res .= '-';
    } else {
        $res .= 'w';
    }

    ($handler, $info) = PVE::API2->find_handler('POST', $path);
    if (!($handler && $info)) {
        $res .= '-';
    } else {
        $res .= 'c';
    }

    ($handler, $info) = PVE::API2->find_handler('DELETE', $path);
    if (!($handler && $info)) {
        $res .= '-';
    } else {
        $res .= 'd';
    }

    return $res;
}

# dynamically update schema definition
# like: pvesh <get|set|create|delete|help> <path>

sub extract_path_info {
    my ($uri_param) = @_;

    my ($handler, $info);

    my $test_path_properties = sub {
        my ($method, $path) = @_;
        ($handler, $info) = PVE::API2->find_handler($method, $path, $uri_param);
    };

    if (defined(my $cmd = $ARGV[0])) {
        if (my $method = $method_map->{$cmd}) {
            if (my $path = $ARGV[1]) {
                $test_path_properties->($method, $path);
                if (!defined($handler)) {
                    print STDERR "No '$cmd' handler defined for '$path'\n";
                    exit(1);
                }
            }
        } elsif ($cmd eq 'bashcomplete') {
            my $cmdline = substr($ENV{COMP_LINE}, 0, $ENV{COMP_POINT});
            my $args = PVE::Tools::split_args($cmdline);
            if (defined(my $cmd = $args->[1])) {
                if (my $method = $method_map->{$cmd}) {
                    if (my $path = $args->[2]) {
                        $test_path_properties->($method, $path);
                    }
                }
            }
        }
    }

    return $info;
}

my $path_properties = {};

my $api_path_property = {
    description => "API path.",
    type => 'string',
    completion => sub {
        my ($cmd, $pname, $cur, $args) = @_;
        return complete_api_path($cur);
    },
};

my $uri_param = {};
if (my $info = extract_path_info($uri_param)) {
    foreach my $key (keys %{ $info->{parameters}->{properties} }) {
        next if defined($uri_param->{$key});
        $path_properties->{$key} = $info->{parameters}->{properties}->{$key};
    }
}

$path_properties->{api_path} = $api_path_property;
$path_properties->{noproxy} = {
    description => "Disable automatic proxying.",
    type => 'boolean',
    optional => 1,
};

my $extract_std_options = 1;

my $cond_add_standard_output_properties = sub {
    my ($props) = @_;

    my $keys =
        [grep { !defined($props->{$_}) } keys %$PVE::RESTHandler::standard_output_options];

    return PVE::RESTHandler::add_standard_output_properties($props, $keys);
};

my $handle_streamed_response = sub {
    my ($download) = @_;
    my ($fh, $path, $encoding, $type) =
        $download->@{ 'fh', 'path', 'content-encoding', 'content-type' };

    die "{download} returned but neither fh nor path given\n"
        if !defined($fh) && !defined($path);

    die "unknown 'content-encoding' $encoding\n" if defined($encoding) && $encoding ne 'gzip';
    die "unknown 'content-type' $type\n"
        if defined($type) && $type !~ qw!^(?:text/plain|application/json)$!;

    if (defined($path)) {
        open($fh, '<', $path) or die "open stream path '$path' for reading failed - $!\n";
    }

    local $/;
    my $data = <$fh>;

    if (defined($encoding)) {
        my $out;
        gunzip(\$data => \$out);
        $data = $out;
    }

    if (defined($type) && $type eq 'application/json') {
        $data = decode_json($data)->{data};
    }

    return $data;
};

sub call_api_method {
    my ($cmd, $param) = @_;

    my $method = $method_map->{$cmd} || die "unable to map command '$cmd'";

    my $path = PVE::Tools::extract_param($param, 'api_path');
    die "missing API path\n" if !defined($path);

    my $stdopts =
        $extract_std_options
        ? PVE::RESTHandler::extract_standard_output_properties($param)
        : {};

    $opt_nooutput = 1 if $stdopts->{quiet};

    my $uri_param = {};
    my ($handler, $info) = PVE::API2->find_handler($method, $path, $uri_param);
    if (!$handler || !$info) {
        die "no '$cmd' handler for '$path'\n";
    }

    my $data;
    my ($node, $remip) = check_proxyto($info, $uri_param, $param);
    if ($node) {
        $data = proxy_handler($node, $remip, $path, $cmd, $param);
    } else {
        foreach my $p (keys %$uri_param) {
            $param->{$p} = $uri_param->{$p};
        }

        $data = $handler->handle($info, $param);

        # TODO: remove 'download' check with PVE 9.0
        if (
            ref($data) eq 'HASH'
            && ref($data->{download}) eq 'HASH'
            && ($info->{download_allowed} || $info->{download})
        ) {
            $data = $handle_streamed_response->($data->{download});
        }
    }

    return if $opt_nooutput || $stdopts->{quiet};

    PVE::CLIFormatter::print_api_result($data, $info->{returns}, undef, $stdopts);
}

__PACKAGE__->register_method({
    name => 'ls',
    path => 'ls',
    method => 'GET',
    description => "List child objects on <api_path>.",
    parameters => {
        additionalProperties => 0,
        properties => $cond_add_standard_output_properties->($path_properties),
    },
    returns => { type => 'null' },
    code => sub {
        my ($param) = @_;

        my $path = PVE::Tools::extract_param($param, 'api_path');

        my $stdopts = PVE::RESTHandler::extract_standard_output_properties($param);

        my $uri_param = {};
        my ($handler, $info) = PVE::API2->find_handler('GET', $path, $uri_param);
        if (!$handler || !$info) {
            die "no such resource '$path'\n";
        }

        my $link = PVE::JSONSchema::method_get_child_link($info);
        die "resource '$path' does not define child links\n" if !$link;

        my $res;

        my ($node, $remip) = check_proxyto($info, $uri_param, $param);
        if ($node) {
            $res = proxy_handler($node, $remip, $path, 'ls', $param);
        } else {
            foreach my $p (keys %$uri_param) {
                $param->{$p} = $uri_param->{$p};
            }

            my $data = $handler->handle($info, $param);

            my $children = extract_children($link, $data);

            $res = [];
            foreach my $c (@$children) {
                my $item = { name => $c, capabilities => resource_cap("$path/$c") };
                push @$res, $item;
            }
        }

        my $schema = { type => 'array', items => { type => 'object' } };
        $stdopts->{sort_key} = 'name';
        $stdopts->{noborder} //= 1;
        $stdopts->{noheader} //= 1;
        PVE::CLIFormatter::print_api_result($res, $schema, ['capabilities', 'name'], $stdopts);

        return undef;
    },
});

__PACKAGE__->register_method({
    name => 'get',
    path => 'get',
    method => 'GET',
    description => "Call API GET on <api_path>.",
    parameters => {
        additionalProperties => 0,
        properties => $cond_add_standard_output_properties->($path_properties),
    },
    returns => { type => 'null' },
    code => sub {
        my ($param) = @_;

        call_api_method('get', $param);

        return undef;
    },
});

__PACKAGE__->register_method({
    name => 'set',
    path => 'set',
    method => 'PUT',
    description => "Call API PUT on <api_path>.",
    parameters => {
        additionalProperties => 0,
        properties => $cond_add_standard_output_properties->($path_properties),
    },
    returns => { type => 'null' },
    code => sub {
        my ($param) = @_;

        call_api_method('set', $param);

        return undef;
    },
});

__PACKAGE__->register_method({
    name => 'create',
    path => 'create',
    method => 'POST',
    description => "Call API POST on <api_path>.",
    parameters => {
        additionalProperties => 0,
        properties => $cond_add_standard_output_properties->($path_properties),
    },
    returns => { type => 'null' },
    code => sub {
        my ($param) = @_;

        call_api_method('create', $param);

        return undef;
    },
});

__PACKAGE__->register_method({
    name => 'delete',
    path => 'delete',
    method => 'DELETE',
    description => "Call API DELETE on <api_path>.",
    parameters => {
        additionalProperties => 0,
        properties => $cond_add_standard_output_properties->($path_properties),
    },
    returns => { type => 'null' },
    code => sub {
        my ($param) = @_;

        call_api_method('delete', $param);

        return undef;
    },
});

__PACKAGE__->register_method({
    name => 'usage',
    path => 'usage',
    method => 'GET',
    description => "print API usage information for <api_path>.",
    parameters => {
        additionalProperties => 0,
        properties => {
            api_path => $api_path_property,
            verbose => {
                description => "Verbose output format.",
                type => 'boolean',
                optional => 1,
            },
            returns => {
                description => "Including schema for returned data.",
                type => 'boolean',
                optional => 1,
            },
            command => {
                description => "API command.",
                type => 'string',
                enum => [keys %$method_map],
                optional => 1,
            },
        },
    },
    returns => { type => 'null' },
    code => sub {
        my ($param) = @_;

        my $path = $param->{api_path};

        my $found = 0;
        foreach my $cmd (qw(get set create delete)) {
            next if $param->{command} && $cmd ne $param->{command};
            my $method = $method_map->{$cmd};
            my $uri_param = {};
            my ($handler, $info) = PVE::API2->find_handler($method, $path, $uri_param);
            next if !$handler;
            $found = 1;

            if ($param->{verbose}) {
                print $handler->usage_str(
                    $info->{name},
                    "pvesh $cmd $path",
                    undef,
                    $uri_param,
                    'full',
                );

            } else {
                print "USAGE: "
                    . $handler->usage_str(
                        $info->{name},
                        "pvesh $cmd $path",
                        undef,
                        $uri_param,
                        'short',
                    );
            }
            if ($param->{returns}) {
                my $schema =
                    to_json($info->{returns}, { utf8 => 1, canonical => 1, pretty => 1 });
                print "RETURNS: $schema\n";
            }
        }

        if (!$found) {
            if ($param->{command}) {
                die "no '$param->{command}' handler for '$path'\n";
            } else {
                die "no such resource '$path'\n";
            }
        }

        return undef;
    },
});

our $cmddef = {
    usage => [__PACKAGE__, 'usage', ['api_path']],
    get => [__PACKAGE__, 'get', ['api_path']],
    ls => [__PACKAGE__, 'ls', ['api_path']],
    set => [__PACKAGE__, 'set', ['api_path']],
    create => [__PACKAGE__, 'create', ['api_path']],
    delete => [__PACKAGE__, 'delete', ['api_path']],
};

1;
