#!/usr/local/cpanel/3rdparty/bin/perl
package Cpanel::AdminBin::Script::Call::Gniza4cp::Restore;

use strict;
use warnings;
use parent 'Cpanel::AdminBin::Script::Call';
use IPC::Open3;
use Symbol 'gensym';
use POSIX qw(setsid);

my $GNIZA4CP_BIN    = '/usr/local/bin/gniza4cp';
my $MAIN_CONFIG  = '/etc/gniza4cp/gniza4cp.conf';
my $REMOTES_DIR  = '/etc/gniza4cp/remotes.d';

# Argument validation patterns (mirrors Gniza4cpWHM::Runner)
my %OPT_PATTERNS = (
    remote    => qr/^[a-zA-Z0-9_,-]+$/,
    timestamp => qr/^\d{4}-\d{2}-\d{2}T\d{6}$/,
    path      => qr/^(?!.*\.\.)[a-zA-Z0-9_.\/@ -]+$/,
    exclude   => qr/^[a-zA-Z0-9_.,\/@ *?\[\]-]+$/,
);

my $ACCOUNT_RE  = qr/^[a-z][a-z0-9_-]*$/;
my $REMOTE_RE   = qr/^[a-zA-Z0-9_-]+$/;
my $DBNAME_RE   = qr/^[a-zA-Z0-9_]+$/;
my $EMAIL_RE    = qr/^[a-zA-Z0-9._+-]+\@[a-zA-Z0-9._-]+$/;
my $DOMAIN_RE   = qr/^[a-zA-Z0-9._-]+$/;
my $TS_RE       = qr/^\d{4}-\d{2}-\d{2}T\d{6}$/;

# ── Allowed remotes for user restore ──────────────────────────

sub _get_allowed_remotes {
    my $setting = '';
    if (open my $fh, '<', $MAIN_CONFIG) {
        while (my $line = <$fh>) {
            if ($line =~ /^USER_RESTORE_REMOTES=(?:"([^"]*)"|'([^']*)'|(\S*))$/) {
                $setting = defined $1 ? $1 : (defined $2 ? $2 : ($3 // ''));
            }
        }
        close $fh;
    }
    # Default to "all" if not set
    $setting = 'all' if !defined $setting || $setting eq '';

    return $setting;
}

sub _list_all_remotes {
    my @remotes;
    if (-d $REMOTES_DIR && opendir my $dh, $REMOTES_DIR) {
        while (my $entry = readdir $dh) {
            if ($entry =~ /^([a-zA-Z0-9_-]+)\.conf$/) {
                push @remotes, $1;
            }
        }
        closedir $dh;
    }
    return sort @remotes;
}

sub _is_remote_allowed {
    my ($remote) = @_;
    my $setting = _get_allowed_remotes();
    return 0 if $setting eq '';  # disabled

    if ($setting eq 'all') {
        # Check it actually exists
        return -f "$REMOTES_DIR/$remote.conf" ? 1 : 0;
    }

    my %allowed = map { $_ => 1 } split /,/, $setting;
    return $allowed{$remote} ? 1 : 0;
}

sub _get_filtered_remotes {
    my $setting = _get_allowed_remotes();
    return () if $setting eq '';

    my @all = _list_all_remotes();
    return @all if $setting eq 'all';

    my %allowed = map { $_ => 1 } split /,/, $setting;
    return grep { $allowed{$_} } @all;
}

# ── Command execution ─────────────────────────────────────────

sub _run_gniza4cp {
    my (@args) = @_;

    my $err_fh = gensym;
    my ($in, $out);
    my $pid = eval { open3($in, $out, $err_fh, $GNIZA4CP_BIN, @args) };
    unless ($pid) {
        return (0, '', "Failed to execute gniza4cp: $@");
    }
    close $in if $in;

    my $stdout = do { local $/; <$out> } // '';
    my $stderr = do { local $/; <$err_fh> } // '';
    close $out;
    close $err_fh;

    waitpid($pid, 0);
    my $exit_code = $? >> 8;

    return ($exit_code == 0, $stdout, $stderr);
}

# ── Action dispatch ───────────────────────────────────────────

# ── Per-user activity logging ─────────────────────────────────

my $ACTIVITY_ENTRY_RE = qr/^[0-9]+$/;

sub _get_log_dir {
    my $log_dir = '/var/log/gniza4cp';
    if (open my $fh, '<', $MAIN_CONFIG) {
        while (my $line = <$fh>) {
            if ($line =~ /^LOG_DIR=(?:"([^"]*)"|'([^']*)'|(\S*))$/) {
                my $val = defined $1 ? $1 : (defined $2 ? $2 : ($3 // ''));
                $log_dir = $val if $val ne '';
            }
        }
        close $fh;
    }
    return $log_dir;
}

sub _activity_log_path {
    my ($user) = @_;
    my $log_dir = _get_log_dir();
    return "$log_dir/cpanel-$user.log";
}

my %ACTION_LABELS = (
    RESTORE_ACCOUNT  => 'Full Account',
    RESTORE_FILES    => 'Home Directory',
    RESTORE_DATABASE => 'Database',
    RESTORE_MAILBOX  => 'Email',
    RESTORE_CRON     => 'Cron Jobs',
    RESTORE_DBUSERS  => 'DB Users',
    RESTORE_DOMAINS  => 'Domains',
    RESTORE_SSL      => 'SSL Certificates',
);

sub _log_activity {
    my ($user, $action, $details, $status, $output) = @_;
    my $log_file = _activity_log_path($user);
    my $log_dir  = _get_log_dir();
    mkdir $log_dir, 0700 unless -d $log_dir;

    my @t  = gmtime(time);
    my $ts = sprintf('%04d-%02d-%02d %02d:%02d:%02d',
        $t[5]+1900, $t[4]+1, $t[3], $t[2], $t[1], $t[0]);
    my $label = $ACTION_LABELS{$action} // $action;

    if (open my $fh, '>>', $log_file) {
        print $fh "--- ENTRY ---\n";
        print $fh "Date: $ts\n";
        print $fh "Action: $label\n";
        print $fh "Details: $details\n";
        print $fh "Status: $status\n";
        print $fh $output if defined $output && $output ne '';
        print $fh "--- END ---\n";
        close $fh;
    }
}

sub _actions {
    return qw(
        LIST_ALLOWED_REMOTES
        LIST_SNAPSHOTS
        LIST_DATABASES
        LIST_MAILBOXES
        LIST_FILES
        LIST_DBUSERS
        LIST_CRON
        LIST_DNS
        LIST_SSL
        LIST_LOGS
        GET_LOG
        START_RESTORE
        RESTORE_ACCOUNT
        RESTORE_FILES
        RESTORE_DATABASE
        RESTORE_MAILBOX
        RESTORE_CRON
        RESTORE_DBUSERS
        RESTORE_DOMAINS
        RESTORE_SSL
    );
}

sub LIST_LOGS {
    my ($self) = @_;
    my $user = $self->get_caller_username() // '';
    return "ERROR: Invalid user" unless $user =~ $ACCOUNT_RE;

    my $log_file = _activity_log_path($user);
    return '' unless -f $log_file && !-l $log_file;

    # Parse entries from the activity log (newest first)
    my @entries;
    if (open my $fh, '<', $log_file) {
        my $in_entry = 0;
        my %cur;
        while (my $line = <$fh>) {
            chomp $line;
            if ($line eq '--- ENTRY ---') {
                $in_entry = 1;
                %cur = ();
            } elsif ($line eq '--- END ---' && $in_entry) {
                push @entries, { %cur } if $cur{date};
                $in_entry = 0;
            } elsif ($in_entry) {
                if ($line =~ /^Date:\s+(.+)$/)    { $cur{date}    = $1; }
                elsif ($line =~ /^Action:\s+(.+)$/) { $cur{action}  = $1; }
                elsif ($line =~ /^Details:\s+(.+)$/) { $cur{details} = $1; }
                elsif ($line =~ /^Status:\s+(.+)$/)  { $cur{status}  = $1; }
            }
        }
        close $fh;
    }

    # Return newest first, one line per entry: index\tdate\taction\tdetails\tstatus
    my @lines;
    for my $i (reverse 0 .. $#entries) {
        my $e = $entries[$i];
        push @lines, join("\t", $i, $e->{date} // '', $e->{action} // '',
                          $e->{details} // '', $e->{status} // '');
    }
    return join("\n", @lines);
}

sub GET_LOG {
    my ($self, $entry_idx) = @_;
    my $user = $self->get_caller_username() // '';
    return "ERROR: Invalid user" unless $user =~ $ACCOUNT_RE;
    return "ERROR: Invalid entry" unless defined $entry_idx && $entry_idx =~ $ACTIVITY_ENTRY_RE;

    my $log_file = _activity_log_path($user);
    return "ERROR: No activity log" unless -f $log_file && !-l $log_file;

    # Parse the Nth entry
    my $idx = int($entry_idx);
    my @entries;
    if (open my $fh, '<', $log_file) {
        my $in_entry = 0;
        my @cur_lines;
        while (my $line = <$fh>) {
            chomp $line;
            if ($line eq '--- ENTRY ---') {
                $in_entry = 1;
                @cur_lines = ();
            } elsif ($line eq '--- END ---' && $in_entry) {
                push @entries, join("\n", @cur_lines);
                $in_entry = 0;
            } elsif ($in_entry) {
                push @cur_lines, $line;
            }
        }
        close $fh;
    }

    return "ERROR: Entry not found" if $idx < 0 || $idx > $#entries;
    return $entries[$idx];
}

sub LIST_ALLOWED_REMOTES {
    my ($self) = @_;
    my @remotes = _get_filtered_remotes();
    return join("\n", @remotes);
}

sub LIST_SNAPSHOTS {
    my ($self, $remote) = @_;
    my $user = $self->get_caller_username() // '';

    return "ERROR: Invalid user"   unless $user =~ $ACCOUNT_RE;
    return "ERROR: Invalid remote" unless defined $remote && $remote =~ $REMOTE_RE;
    return "ERROR: Remote not allowed" unless _is_remote_allowed($remote);

    my ($ok, $stdout, $stderr) = _run_gniza4cp('list', "--remote=$remote", "--account=$user");
    return $ok ? $stdout : "ERROR: $stderr";
}

sub LIST_DATABASES {
    my ($self, $remote, $timestamp) = @_;
    my $user = $self->get_caller_username() // '';

    return "ERROR: Invalid user"      unless $user =~ $ACCOUNT_RE;
    return "ERROR: Invalid remote"    unless defined $remote && $remote =~ $REMOTE_RE;
    return "ERROR: Invalid timestamp" unless defined $timestamp && $timestamp =~ $TS_RE;
    return "ERROR: Remote not allowed" unless _is_remote_allowed($remote);

    my ($ok, $stdout, $stderr) = _run_gniza4cp('restore', 'list-databases', $user,
        "--remote=$remote", "--timestamp=$timestamp");
    return $ok ? $stdout : "ERROR: $stderr";
}

sub LIST_MAILBOXES {
    my ($self, $remote, $timestamp) = @_;
    my $user = $self->get_caller_username() // '';

    return "ERROR: Invalid user"      unless $user =~ $ACCOUNT_RE;
    return "ERROR: Invalid remote"    unless defined $remote && $remote =~ $REMOTE_RE;
    return "ERROR: Invalid timestamp" unless defined $timestamp && $timestamp =~ $TS_RE;
    return "ERROR: Remote not allowed" unless _is_remote_allowed($remote);

    my ($ok, $stdout, $stderr) = _run_gniza4cp('restore', 'list-mailboxes', $user,
        "--remote=$remote", "--timestamp=$timestamp");
    return $ok ? $stdout : "ERROR: $stderr";
}

sub LIST_FILES {
    my ($self, $remote, $timestamp, $path) = @_;
    my $user = $self->get_caller_username() // '';

    return "ERROR: Invalid user"      unless $user =~ $ACCOUNT_RE;
    return "ERROR: Invalid remote"    unless defined $remote && $remote =~ $REMOTE_RE;
    return "ERROR: Invalid timestamp" unless defined $timestamp && $timestamp =~ $TS_RE;
    return "ERROR: Remote not allowed" unless _is_remote_allowed($remote);

    my @opts = ("--remote=$remote", "--timestamp=$timestamp");
    if (defined $path && $path ne '') {
        return "ERROR: Invalid path" unless $path =~ $OPT_PATTERNS{path};
        push @opts, "--path=$path";
    }

    my ($ok, $stdout, $stderr) = _run_gniza4cp('restore', 'list-files', $user, @opts);
    return $ok ? $stdout : "ERROR: $stderr";
}

sub LIST_DBUSERS {
    my ($self, $remote, $timestamp) = @_;
    my $user = $self->get_caller_username() // '';

    return "ERROR: Invalid user"      unless $user =~ $ACCOUNT_RE;
    return "ERROR: Invalid remote"    unless defined $remote && $remote =~ $REMOTE_RE;
    return "ERROR: Invalid timestamp" unless defined $timestamp && $timestamp =~ $TS_RE;
    return "ERROR: Remote not allowed" unless _is_remote_allowed($remote);

    my ($ok, $stdout, $stderr) = _run_gniza4cp('restore', 'list-dbusers', $user,
        "--remote=$remote", "--timestamp=$timestamp");
    return $ok ? $stdout : "ERROR: $stderr";
}

sub LIST_CRON {
    my ($self, $remote, $timestamp) = @_;
    my $user = $self->get_caller_username() // '';

    return "ERROR: Invalid user"      unless $user =~ $ACCOUNT_RE;
    return "ERROR: Invalid remote"    unless defined $remote && $remote =~ $REMOTE_RE;
    return "ERROR: Invalid timestamp" unless defined $timestamp && $timestamp =~ $TS_RE;
    return "ERROR: Remote not allowed" unless _is_remote_allowed($remote);

    my ($ok, $stdout, $stderr) = _run_gniza4cp('restore', 'list-cron', $user,
        "--remote=$remote", "--timestamp=$timestamp");
    return $ok ? $stdout : "ERROR: $stderr";
}

sub LIST_DNS {
    my ($self, $remote, $timestamp) = @_;
    my $user = $self->get_caller_username() // '';

    return "ERROR: Invalid user"      unless $user =~ $ACCOUNT_RE;
    return "ERROR: Invalid remote"    unless defined $remote && $remote =~ $REMOTE_RE;
    return "ERROR: Invalid timestamp" unless defined $timestamp && $timestamp =~ $TS_RE;
    return "ERROR: Remote not allowed" unless _is_remote_allowed($remote);

    my ($ok, $stdout, $stderr) = _run_gniza4cp('restore', 'list-dns', $user,
        "--remote=$remote", "--timestamp=$timestamp");
    return $ok ? $stdout : "ERROR: $stderr";
}

sub LIST_SSL {
    my ($self, $remote, $timestamp) = @_;
    my $user = $self->get_caller_username() // '';

    return "ERROR: Invalid user"      unless $user =~ $ACCOUNT_RE;
    return "ERROR: Invalid remote"    unless defined $remote && $remote =~ $REMOTE_RE;
    return "ERROR: Invalid timestamp" unless defined $timestamp && $timestamp =~ $TS_RE;
    return "ERROR: Remote not allowed" unless _is_remote_allowed($remote);

    my ($ok, $stdout, $stderr) = _run_gniza4cp('restore', 'list-ssl', $user,
        "--remote=$remote", "--timestamp=$timestamp");
    return $ok ? $stdout : "ERROR: $stderr";
}

# ── Background restore ────────────────────────────────────────
#
# START_RESTORE($remote, $timestamp, $types_str, $path, $exclude_paths)
#
# $types_str encodes selected types and items, semicolon-separated:
#   account;files;database:db1,db2;mailbox:a@b.com;cron;dbusers:u1;domains:d1;ssl:d1
#
# Validates all inputs, forks a detached child that runs each gniza4cp
# restore command and logs results via _log_activity(), then returns
# immediately with "OK".

my %TYPE_ITEM_RE = (
    account  => undef,          # no items
    files    => undef,          # uses $path arg instead
    cron     => undef,          # no items
    database => $DBNAME_RE,
    dbusers  => $DBNAME_RE,
    mailbox  => $EMAIL_RE,
    domains  => $DOMAIN_RE,
    ssl      => $DOMAIN_RE,
);

# Map type + item to gniza4cp CLI arguments (excluding --remote/--timestamp which are always added)
sub _build_restore_args {
    my ($type, $user, $item, $path, $exclude) = @_;
    if ($type eq 'account') {
        my @args = ('restore', 'account', $user);
        push @args, "--exclude=$exclude" if defined $exclude && $exclude ne '';
        return @args;
    }
    elsif ($type eq 'files') {
        my @args = ('restore', 'files', $user);
        push @args, "--path=$path" if defined $path && $path ne '';
        push @args, "--exclude=$exclude" if defined $exclude && $exclude ne '';
        return @args;
    }
    elsif ($type eq 'cron') {
        return ('restore', 'cron', $user);
    }
    elsif ($type eq 'database') {
        my @args = ('restore', 'database', $user);
        push @args, $item if defined $item && $item ne '';
        return @args;
    }
    elsif ($type eq 'dbusers') {
        my @args = ('restore', 'dbusers', $user);
        push @args, $item if defined $item && $item ne '';
        return @args;
    }
    elsif ($type eq 'mailbox') {
        my @args = ('restore', 'mailbox', $user);
        push @args, $item if defined $item && $item ne '';
        return @args;
    }
    elsif ($type eq 'domains') {
        my @args = ('restore', 'domains', $user);
        push @args, $item if defined $item && $item ne '';
        return @args;
    }
    elsif ($type eq 'ssl') {
        my @args = ('restore', 'ssl', $user);
        push @args, $item if defined $item && $item ne '';
        return @args;
    }
    return ();
}

# Map type to RESTORE_* action name for _log_activity
my %TYPE_ACTION_MAP = (
    account  => 'RESTORE_ACCOUNT',
    files    => 'RESTORE_FILES',
    database => 'RESTORE_DATABASE',
    dbusers  => 'RESTORE_DBUSERS',
    mailbox  => 'RESTORE_MAILBOX',
    cron     => 'RESTORE_CRON',
    domains  => 'RESTORE_DOMAINS',
    ssl      => 'RESTORE_SSL',
);

sub START_RESTORE {
    my ($self, $remote, $timestamp, $types_str, $path, $exclude_paths) = @_;
    my $user = $self->get_caller_username() // '';

    # ── Validate common args ──
    return "ERROR: Invalid user"      unless $user =~ $ACCOUNT_RE;
    return "ERROR: Invalid remote"    unless defined $remote && $remote =~ $REMOTE_RE;
    return "ERROR: Invalid timestamp" unless defined $timestamp && $timestamp =~ $TS_RE;
    return "ERROR: Remote not allowed" unless _is_remote_allowed($remote);

    if (defined $path && $path ne '') {
        return "ERROR: Invalid path" unless $path =~ $OPT_PATTERNS{path};
    }
    if (defined $exclude_paths && $exclude_paths ne '') {
        return "ERROR: Invalid exclude" unless $exclude_paths =~ $OPT_PATTERNS{exclude};
    }

    # ── Parse and validate types_str ──
    $types_str //= '';
    return "ERROR: No restore types specified" if $types_str eq '';

    my @jobs;  # [ { type => ..., items => [...] }, ... ]
    for my $part (split /;/, $types_str) {
        next if $part eq '';
        my ($type, $items_str) = split /:/, $part, 2;

        return "ERROR: Invalid restore type: $type" unless exists $TYPE_ITEM_RE{$type};

        my $item_re = $TYPE_ITEM_RE{$type};
        my @items;

        if (defined $items_str && $items_str ne '') {
            return "ERROR: Type '$type' does not accept items" unless defined $item_re;
            for my $item (split /,/, $items_str) {
                next if $item eq '';
                return "ERROR: Invalid item for $type: $item" unless $item =~ $item_re;
                push @items, $item;
                return "ERROR: Too many items for $type (max 100)" if @items > 100;
            }
        }

        push @jobs, { type => $type, items => \@items };
    }

    return "ERROR: No valid restore types parsed" unless @jobs;

    # ── Pre-build all command arg lists to validate before forking ──
    my @cmd_list;  # [ { args => [...], action => ..., details => ... }, ... ]
    for my $job (@jobs) {
        my $type  = $job->{type};
        my @items = @{$job->{items}};

        if (@items) {
            # One command per item
            for my $item (@items) {
                my @args = _build_restore_args($type, $user, $item, $path, $exclude_paths);
                return "ERROR: Failed to build command for $type" unless @args;
                my $details = "remote=$remote snapshot=$timestamp";
                if ($type eq 'database')    { $details .= " database=$item"; }
                elsif ($type eq 'dbusers')  { $details .= " dbuser=$item"; }
                elsif ($type eq 'mailbox')  { $details .= " email=$item"; }
                elsif ($type eq 'domains')  { $details .= " domain=$item"; }
                elsif ($type eq 'ssl')      { $details .= " domain=$item"; }
                push @cmd_list, { args => \@args, action => $TYPE_ACTION_MAP{$type}, details => $details };
            }
        } else {
            # Single command for this type
            my @args = _build_restore_args($type, $user, '', $path, $exclude_paths);
            return "ERROR: Failed to build command for $type" unless @args;
            my $details = "remote=$remote snapshot=$timestamp";
            $details .= " path=$path" if $type eq 'files' && defined $path && $path ne '';
            $details .= " exclude=$exclude_paths" if defined $exclude_paths && $exclude_paths ne '';
            push @cmd_list, { args => \@args, action => $TYPE_ACTION_MAP{$type}, details => $details };
        }
    }

    # ── Fork detached child ──
    local $SIG{CHLD} = 'IGNORE';
    my $pid = fork();
    return "ERROR: Fork failed: $!" unless defined $pid;

    if ($pid == 0) {
        # Child: detach from parent completely
        eval {
            setsid();
            open STDIN,  '<', '/dev/null';
            open STDOUT, '>', '/dev/null';
            open STDERR, '>', '/dev/null';

            for my $cmd (@cmd_list) {
                my @full_args = (@{$cmd->{args}}, "--remote=$remote", "--timestamp=$timestamp");
                my ($ok, $stdout, $stderr) = _run_gniza4cp(@full_args);
                _log_activity($user, $cmd->{action}, $cmd->{details},
                    $ok ? 'OK' : 'Error', $ok ? $stdout : $stderr);
            }
        };
        # Ensure child exits even on error
        POSIX::_exit(0);
    }

    # Parent: return immediately
    return "OK";
}

sub RESTORE_ACCOUNT {
    my ($self, $remote, $timestamp, $exclude) = @_;
    my $user = $self->get_caller_username() // '';

    return "ERROR: Invalid user"      unless $user =~ $ACCOUNT_RE;
    return "ERROR: Invalid remote"    unless defined $remote && $remote =~ $REMOTE_RE;
    return "ERROR: Invalid timestamp" unless defined $timestamp && $timestamp =~ $TS_RE;
    return "ERROR: Remote not allowed" unless _is_remote_allowed($remote);

    my @opts = ("--remote=$remote", "--timestamp=$timestamp");
    # NOTE: --terminate is NEVER passed for user restore
    if (defined $exclude && $exclude ne '') {
        return "ERROR: Invalid exclude" unless $exclude =~ $OPT_PATTERNS{exclude};
        push @opts, "--exclude=$exclude";
    }

    my $details = "remote=$remote snapshot=$timestamp";
    $details .= " exclude=$exclude" if defined $exclude && $exclude ne '';

    my ($ok, $stdout, $stderr) = _run_gniza4cp('restore', 'account', $user, @opts);
    _log_activity($user, 'RESTORE_ACCOUNT', $details,
        $ok ? 'OK' : 'Error', $ok ? $stdout : $stderr);
    return $ok ? "OK\n$stdout" : "ERROR: $stderr";
}

sub RESTORE_FILES {
    my ($self, $remote, $timestamp, $path, $exclude) = @_;
    my $user = $self->get_caller_username() // '';

    return "ERROR: Invalid user"      unless $user =~ $ACCOUNT_RE;
    return "ERROR: Invalid remote"    unless defined $remote && $remote =~ $REMOTE_RE;
    return "ERROR: Invalid timestamp" unless defined $timestamp && $timestamp =~ $TS_RE;
    return "ERROR: Remote not allowed" unless _is_remote_allowed($remote);

    my @opts = ("--remote=$remote", "--timestamp=$timestamp");
    if (defined $path && $path ne '') {
        return "ERROR: Invalid path" unless $path =~ $OPT_PATTERNS{path};
        push @opts, "--path=$path";
    }
    if (defined $exclude && $exclude ne '') {
        return "ERROR: Invalid exclude" unless $exclude =~ $OPT_PATTERNS{exclude};
        push @opts, "--exclude=$exclude";
    }

    my $details = "remote=$remote snapshot=$timestamp";
    $details .= " path=$path" if defined $path && $path ne '';
    $details .= " exclude=$exclude" if defined $exclude && $exclude ne '';

    my ($ok, $stdout, $stderr) = _run_gniza4cp('restore', 'files', $user, @opts);
    _log_activity($user, 'RESTORE_FILES', $details,
        $ok ? 'OK' : 'Error', $ok ? $stdout : $stderr);
    return $ok ? "OK\n$stdout" : "ERROR: $stderr";
}

sub RESTORE_DATABASE {
    my ($self, $remote, $timestamp, $dbname) = @_;
    my $user = $self->get_caller_username() // '';

    return "ERROR: Invalid user"      unless $user =~ $ACCOUNT_RE;
    return "ERROR: Invalid remote"    unless defined $remote && $remote =~ $REMOTE_RE;
    return "ERROR: Invalid timestamp" unless defined $timestamp && $timestamp =~ $TS_RE;
    return "ERROR: Remote not allowed" unless _is_remote_allowed($remote);

    my @args = ($user);
    if (defined $dbname && $dbname ne '') {
        return "ERROR: Invalid database name" unless $dbname =~ $DBNAME_RE;
        push @args, $dbname;
    }

    my $details = "remote=$remote snapshot=$timestamp";
    $details .= " database=$dbname" if defined $dbname && $dbname ne '';

    my ($ok, $stdout, $stderr) = _run_gniza4cp('restore', 'database', @args,
        "--remote=$remote", "--timestamp=$timestamp");
    _log_activity($user, 'RESTORE_DATABASE', $details,
        $ok ? 'OK' : 'Error', $ok ? $stdout : $stderr);
    return $ok ? "OK\n$stdout" : "ERROR: $stderr";
}

sub RESTORE_MAILBOX {
    my ($self, $remote, $timestamp, $email) = @_;
    my $user = $self->get_caller_username() // '';

    return "ERROR: Invalid user"      unless $user =~ $ACCOUNT_RE;
    return "ERROR: Invalid remote"    unless defined $remote && $remote =~ $REMOTE_RE;
    return "ERROR: Invalid timestamp" unless defined $timestamp && $timestamp =~ $TS_RE;
    return "ERROR: Remote not allowed" unless _is_remote_allowed($remote);

    my @args = ($user);
    if (defined $email && $email ne '') {
        return "ERROR: Invalid email" unless $email =~ $EMAIL_RE;
        push @args, $email;
    }

    my $details = "remote=$remote snapshot=$timestamp";
    $details .= " email=$email" if defined $email && $email ne '';

    my ($ok, $stdout, $stderr) = _run_gniza4cp('restore', 'mailbox', @args,
        "--remote=$remote", "--timestamp=$timestamp");
    _log_activity($user, 'RESTORE_MAILBOX', $details,
        $ok ? 'OK' : 'Error', $ok ? $stdout : $stderr);
    return $ok ? "OK\n$stdout" : "ERROR: $stderr";
}

sub RESTORE_CRON {
    my ($self, $remote, $timestamp) = @_;
    my $user = $self->get_caller_username() // '';

    return "ERROR: Invalid user"      unless $user =~ $ACCOUNT_RE;
    return "ERROR: Invalid remote"    unless defined $remote && $remote =~ $REMOTE_RE;
    return "ERROR: Invalid timestamp" unless defined $timestamp && $timestamp =~ $TS_RE;
    return "ERROR: Remote not allowed" unless _is_remote_allowed($remote);

    my $details = "remote=$remote snapshot=$timestamp";

    my ($ok, $stdout, $stderr) = _run_gniza4cp('restore', 'cron', $user,
        "--remote=$remote", "--timestamp=$timestamp");
    _log_activity($user, 'RESTORE_CRON', $details,
        $ok ? 'OK' : 'Error', $ok ? $stdout : $stderr);
    return $ok ? "OK\n$stdout" : "ERROR: $stderr";
}

sub RESTORE_DBUSERS {
    my ($self, $remote, $timestamp, $dbuser) = @_;
    my $user = $self->get_caller_username() // '';

    return "ERROR: Invalid user"      unless $user =~ $ACCOUNT_RE;
    return "ERROR: Invalid remote"    unless defined $remote && $remote =~ $REMOTE_RE;
    return "ERROR: Invalid timestamp" unless defined $timestamp && $timestamp =~ $TS_RE;
    return "ERROR: Remote not allowed" unless _is_remote_allowed($remote);

    my @args = ($user);
    if (defined $dbuser && $dbuser ne '') {
        return "ERROR: Invalid database user" unless $dbuser =~ $DBNAME_RE;
        push @args, $dbuser;
    }

    my $details = "remote=$remote snapshot=$timestamp";
    $details .= " dbuser=$dbuser" if defined $dbuser && $dbuser ne '';

    my ($ok, $stdout, $stderr) = _run_gniza4cp('restore', 'dbusers', @args,
        "--remote=$remote", "--timestamp=$timestamp");
    _log_activity($user, 'RESTORE_DBUSERS', $details,
        $ok ? 'OK' : 'Error', $ok ? $stdout : $stderr);
    return $ok ? "OK\n$stdout" : "ERROR: $stderr";
}

sub RESTORE_DOMAINS {
    my ($self, $remote, $timestamp, $domain) = @_;
    my $user = $self->get_caller_username() // '';

    return "ERROR: Invalid user"      unless $user =~ $ACCOUNT_RE;
    return "ERROR: Invalid remote"    unless defined $remote && $remote =~ $REMOTE_RE;
    return "ERROR: Invalid timestamp" unless defined $timestamp && $timestamp =~ $TS_RE;
    return "ERROR: Remote not allowed" unless _is_remote_allowed($remote);

    my @args = ($user);
    if (defined $domain && $domain ne '') {
        return "ERROR: Invalid domain" unless $domain =~ $DOMAIN_RE;
        push @args, $domain;
    }

    my $details = "remote=$remote snapshot=$timestamp";
    $details .= " domain=$domain" if defined $domain && $domain ne '';

    my ($ok, $stdout, $stderr) = _run_gniza4cp('restore', 'domains', @args,
        "--remote=$remote", "--timestamp=$timestamp");
    _log_activity($user, 'RESTORE_DOMAINS', $details,
        $ok ? 'OK' : 'Error', $ok ? $stdout : $stderr);
    return $ok ? "OK\n$stdout" : "ERROR: $stderr";
}

sub RESTORE_SSL {
    my ($self, $remote, $timestamp, $domain) = @_;
    my $user = $self->get_caller_username() // '';

    return "ERROR: Invalid user"      unless $user =~ $ACCOUNT_RE;
    return "ERROR: Invalid remote"    unless defined $remote && $remote =~ $REMOTE_RE;
    return "ERROR: Invalid timestamp" unless defined $timestamp && $timestamp =~ $TS_RE;
    return "ERROR: Remote not allowed" unless _is_remote_allowed($remote);

    my @args = ($user);
    if (defined $domain && $domain ne '') {
        return "ERROR: Invalid domain" unless $domain =~ $DOMAIN_RE;
        push @args, $domain;
    }

    my $details = "remote=$remote snapshot=$timestamp";
    $details .= " domain=$domain" if defined $domain && $domain ne '';

    my ($ok, $stdout, $stderr) = _run_gniza4cp('restore', 'ssl', @args,
        "--remote=$remote", "--timestamp=$timestamp");
    _log_activity($user, 'RESTORE_SSL', $details,
        $ok ? 'OK' : 'Error', $ok ? $stdout : $stderr);
    return $ok ? "OK\n$stdout" : "ERROR: $stderr";
}

__PACKAGE__->run() if !caller;

1;
