#!/usr/local/cpanel/3rdparty/bin/perl

# Files.ir Backup - AdminBin bridge
# Runs privileged on behalf of a cPanel user; every action is scoped
# to the calling user only.

package bin::admin::FilesIR::backup;

use strict;
use utf8;
use warnings;
use lib '/usr/local/filesir_backup/lib';

use parent 'Cpanel::AdminBin::Script::Call';

use FilesIR::Config ();
use FilesIR::DB     ();
use FilesIR::Engine ();
use FilesIR::API    ();
use FilesIR::Util   ();

__PACKAGE__->run( 'alarm' => 350 ) unless caller;

sub _actions {
    return qw(GET_STATUS LIST_BACKUPS REQUEST_RESTORE GET_LINK LIST_REQUESTS SET_PERSONAL_TOKEN REMOVE_PERSONAL_TOKEN SET_PERSONAL_JOB SET_PERSONAL_WS GET_PERSONAL_ITEMS DELETE_PERSONAL_JOB RUN_PERSONAL_NOW LIST_MY_ACTIVITY ACCEPT_CONSENT);
}

my %_BTL = (
    full      => { fa => 'فول اکانت',   en => 'Full account' },
    databases => { fa => 'دیتابیس‌ها',  en => 'Databases' },
    email     => { fa => 'ایمیل‌ها',    en => 'Emails' },
);

sub _act_titles {
    my ($it) = @_;
    my $bt = $_BTL{ $it->{btype} || 'full' } || $_BTL{full};
    my $n  = $it->{backup_n} || 0;
    my $bd = $it->{backup_h} || '';
    # jalali_date() already carries the clock, so don't append backup_hm again
    my $bp = $bd ? " ($bd)" : '';
    if ( ( $it->{kind} || '' ) eq 'restore' ) {
        return ( "بازگردانی نسخهٔ #$n$bp", "Restore of copy #$n$bp" );
    }
    if ( ( $it->{kind} || '' ) eq 'download' ) {
        return ( "درخواست لینک دانلود نسخهٔ #$n$bp", "Download link request for copy #$n$bp" );
    }
    return ( "بکاپ‌گیری ($bt->{fa})", "Backup ($bt->{en})" );
}

sub _cp_log_allows {
    my ( $cfg, $side, $key ) = @_;
    return ( $cfg->{cp_log}{$side}{$key} // 1 ) ? 1 : 0;
}

sub REQUEST_SELF_RESTORE {
    my ( $self, $src_kind, $src, $mode, $panel ) = @_;
    my $user   = $self->get_caller_username();
    my $engine = $self->_engine();
    my $cfg    = $engine->cfg;
    die "module disabled\n" if !$cfg->{enabled};
    $self->_require_consent( $engine, $user );
    die "این قابلیت توسط مدیر سرور فعال نشده است.\n"
      if !( $cfg->{user_features}{self_restore} // 0 );
    die "restore already running for this account\n"
      if $engine->db->has_active_job( 'restore', $user );

    $src_kind = ( $src_kind // '' ) eq 'url' ? 'url' : 'file';
    my $srcs = $cfg->{user_features}{self_restore_sources} || {};
    my %s_allow = (
        upload => ( $srcs->{upload} // 1 ),
        url    => ( $srcs->{url}    // 1 ),
        file   => ( $srcs->{file}   // 1 ),
    );
    my $eff = $src_kind eq 'url' ? 'url'
      : ( $src // '' ) =~ m{\A\.filesir-restore-uploads/} ? 'upload'
      :                                                       'file';
    die "این منبع آرشیو توسط مدیر سرور غیرفعال شده است.\n" if !$s_allow{$eff};
    $src //= '';
    $src =~ s/\A\s+|\s+\z//g;
    die "منبع آرشیو خالی است.\n" if !length $src;
    die "invalid source\n" if length($src) > 2048 || $src =~ /[\0\r\n]/;
    if ( $src_kind eq 'file' ) {
        die "مسیر نامعتبر است.\n" if $src =~ m{\.\.};
        die "فقط آرشیوهای tar پذیرفته می‌شوند (.tar.gz .tgz .tar .tar.zst .tzst)\n"
          if $src !~ /\.(?:tar\.gz|tgz|tar|tar\.zst|tzst|zip)\z/i;
    }
    else {
        die "فقط لینک http/https مجاز است.\n" if $src !~ m{\Ahttps?://}i;
    }
    $mode  = ( $mode // '' ) eq 'terminate' ? 'terminate' : 'overwrite';
    $panel = ( $panel // 'cpanel' );
    $panel = 'cpanel' if $panel !~ /\A(?:cpanel|directadmin|plesk)\z/;
    my $modes = $cfg->{user_features}{self_restore_modes} || {};
    my %allow = ( overwrite => ( $modes->{overwrite} // 1 ), terminate => ( $modes->{terminate} // 1 ) );
    die $mode eq 'terminate'
      ? "حالت «حذف و بازسازی» توسط مدیر سرور غیرفعال شده است.\n"
      : "حالت «بازنویسی» توسط مدیر سرور غیرفعال شده است.\n"
      if !$allow{$mode};

    if ( $src_kind eq 'file' && $src =~ m{\A\.filesir-restore-uploads/} ) {
        my $h = ( getpwnam($user) )[7] || ( ( $ENV{FILESIR_HOME_ROOT} || '/home' ) . "/$user" );
        utime undef, undef, "$h/$src" if -f "$h/$src";    # restart the 10-min window
    }
    my $jid = $engine->db->enqueue_job(
        type    => 'restore', username => $user, origin => 'personal',
        payload => { self => 1, src_kind => $src_kind, src => $src, restore_mode => $mode, panel => $panel },
    );
    FilesIR::Engine::spawn_engine();
    $engine->log( 'info', $user,
        $engine->L( 'ریستور بکاپ کاربر: آرشیو از %s با حالت %s به صف رفت (کار #%d)',
            'self-restore: archive from %s queued in %s mode (job #%d)', $src_kind, $mode, $jid ) );
    return { queued => 1, job_id => $jid };
}

sub _job_hidden_from_user {
    my ( $db, $b, $cfg ) = @_;
    return 0 if !$b || $b->{personal};
    return 1 if $cfg && !( $cfg->{user_features}{server_backups} // 1 );
    return 1 if $cfg && !$b->{bjob_id} && !( $cfg->{user_features}{manual_backups} // 0 );
    return 0 if !$b->{bjob_id};
    my $bj = $db->get_bjob( $b->{bjob_id} );
    return ( $bj && !( $bj->{visible} // 1 ) ) ? 1 : 0;
}

sub _hm {
    my ($e) = @_;
    my @lt = localtime( $e || time() );
    return sprintf( '%02d:%02d', $lt[2], $lt[1] );
}

sub _engine {
    my ($self) = @_;
    $self->{_filesir_engine} ||= FilesIR::Engine->new();
    return $self->{_filesir_engine};
}

sub GET_STATUS {
    my ($self) = @_;
    my $user   = $self->get_caller_username();
    my $engine = $self->_engine();
    my $cfg    = $engine->cfg;

    my $acct_enabled = FilesIR::Config::account_enabled( $cfg, $user );
    my $pjs = $engine->db->list_user_jobs($user);
    my $pj  = $pjs->[0];
    return {
        personal_jobs => [
            map {
                { id => $_->{id}, enabled => $_->{enabled} ? 1 : 0, btype => $_->{btype},
                  sched => $_->{sched}, items => ( $_->{items} || [] ),
                  excludes => ( $_->{excludes} || [] ),
                  retention_days => $_->{retention_days} }
            } @$pjs
        ],
        personal_max_jobs => int( $cfg->{personal_max_jobs} // 1 ),
        backup_wizard  => ( $cfg->{user_features}{backup_wizard} ? 1 : 0 ),
        personal_job => $pj
        ? { enabled => $pj->{enabled} ? 1 : 0, btype => $pj->{btype},
            sched => $pj->{sched}, items => ( $pj->{items} || [] ),
            retention_days => $pj->{retention_days} }
        : undef,
        personal_freqs => {
            daily   => $cfg->{personal_freqs}{daily}   ? 1 : 0,
            weekly  => $cfg->{personal_freqs}{weekly}  ? 1 : 0,
            monthly => $cfg->{personal_freqs}{monthly} ? 1 : 0,
        },
        version        => $FilesIR::Util::VERSION,
        retention_show => do {
            my $mx = 0;
            for my $bj ( @{ $engine->db->list_bjobs() } ) {
                next if !$bj->{enabled} || !( $bj->{visible} // 1 );
                $mx = $bj->{retention_days} if ( $bj->{retention_days} || 0 ) > $mx;
            }
            $mx || int( $cfg->{retention_days} // 7 );
        },
        log_kinds      => {
            backup   => ( $cfg->{user_log_kinds}{backup}   ? 1 : 0 ),
            restore  => ( $cfg->{user_log_kinds}{restore}  ? 1 : 0 ),
            download => ( $cfg->{user_log_kinds}{download} ? 1 : 0 ),
        },
        module_enabled => ( $cfg->{enabled}  ? 1 : 0 ),
        eligible       => ( $cfg->{eligible} ? 1 : 0 ),
        account_enabled=> ( $acct_enabled    ? 1 : 0 ),
        restore_mode   => $cfg->{restore_mode},
        retention_days => $cfg->{retention_days},
        features       => {
            view             => ( $cfg->{user_features}{view}          ? 1 : 0 ),
            restore          => ( $cfg->{user_features}{restore}       ? 1 : 0 ),
            download_link    => ( $cfg->{user_features}{download_link} ? 1 : 0 ),
            personal         => ( $cfg->{user_features}{personal_key}  ? 1 : 0 ),
            personal_restore => ( $cfg->{user_features}{personal_restore} ? 1 : 0 ),
            self_restore     => ( ( $cfg->{user_features}{self_restore} // 0 ) ? 1 : 0 ),
            self_restore_modes => {
                overwrite => ( ( $cfg->{user_features}{self_restore_modes}{overwrite} // 1 ) ? 1 : 0 ),
                terminate => ( ( $cfg->{user_features}{self_restore_modes}{terminate} // 1 ) ? 1 : 0 ),
            },
            self_restore_sources => {
                upload => ( ( $cfg->{user_features}{self_restore_sources}{upload} // 1 ) ? 1 : 0 ),
                url    => ( ( $cfg->{user_features}{self_restore_sources}{url}    // 1 ) ? 1 : 0 ),
                file   => ( ( $cfg->{user_features}{self_restore_sources}{file}   // 1 ) ? 1 : 0 ),
            },
        },
        link_expiry_hours => $cfg->{link_expiry_hours},
        consent => {
            required => ( $cfg->{user_consent}{enabled} ? 1 : 0 ),
            accepted => ( $engine->db->get_kv("consent_$user") ? 1 : 0 ),
            text     => ( $cfg->{user_consent}{text} // '' ),
        },
        restore_warn => {
            enabled => ( $cfg->{user_restore_warn}{enabled} ? 1 : 0 ),
            text    => ( $cfg->{user_restore_warn}{text} // '' ),
        },
        personal          => $self->_personal_status( $engine, $user, $cfg ),
    };
}

sub _personal_status {
    my ( $self, $engine, $user, $cfg ) = @_;
    my %p = ( feature => ( $cfg->{user_features}{personal_key} ? 1 : 0 ), has_token => 0 );
    return \%p if !$p{feature};
    my $row = $engine->db->get_user_token($user);
    if ( $row && $row->{token} ) {
        $p{has_token} = 1;
        my $papi = $engine->personal_api($user);
        if ($papi) {
            my ( $usage, $err ) = $papi->space_usage();
            if ( !$err && $usage ) {
                my $used  = $usage->{used} // 0;
                my $avail = $usage->{available};
                $p{used_h}  = FilesIR::Util::human_bytes($used);
                $p{total_h} = defined $avail ? FilesIR::Util::human_bytes($avail) : 'Unlimited';
                $p{used_pct} =
                  ( defined $avail && $avail > 0 )
                  ? int( $used * 100 / $avail + 0.5 )
                  : 0;
                $p{used_pct} = 100 if $p{used_pct} > 100;
            }
            my ( $wss, undef ) = $self->_ws_list($papi);
            $p{workspaces}   = ref $wss eq 'ARRAY' ? $wss : [];
            $p{workspace_id} = $row->{workspace_id} || 0;
        }
    }
    return \%p;
}

sub _ws_list {
    my ( $self, $papi ) = @_;
    my ( $list, $err ) = $papi->workspaces();
    return ( undef, $err ) if $err;
    unshift @$list, { id => 0, name => 'Personal' };
    return ( $list, undef );
}

sub _require_consent {
    my ( $self, $engine, $user ) = @_;
    my $cfg = $engine->cfg;
    return 1 if !$cfg->{user_consent}{enabled};
    return 1 if $engine->db->get_kv("consent_$user");
    die "ابتدا پیام تایید ورود را بپذیرید.\n";
}

sub ACCEPT_CONSENT {
    my ($self) = @_;
    my $user   = $self->get_caller_username();
    my $engine = $self->_engine();
    $engine->db->set_kv( "consent_$user", time() );
    $engine->db->log( 'info', $user, 'user accepted the cPanel entry notice' );
    return { accepted => 1 };
}

sub SET_PERSONAL_WS {
    my ( $self, $ws ) = @_;
    my $user   = $self->get_caller_username();
    my $engine = $self->_engine();
    $self->_require_consent( $engine, $user );
    my $cfg = $engine->cfg;
    die "feature disabled\n" if !$cfg->{user_features}{personal_key};
    my $row = $engine->db->get_user_token($user);
    die "ابتدا کلید API شخصی خود را ثبت کنید.\n" if !$row || !$row->{token};
    $ws = int( $ws // 0 );
    my $papi = $engine->personal_api($user);
    my ( $wss, $werr ) = $self->_ws_list($papi);
    die "cannot list workspaces: $werr\n" if $werr;
    die "invalid workspace\n" if !grep { ( $_->{id} // -1 ) == $ws } @$wss;
    $engine->db->set_user_workspace( $user, $ws );
    my $fresh = $engine->personal_api($user);
    my ( undef, $ferr ) = $engine->ensure_personal_root( $fresh, $user );
    $engine->log( 'warn', $user, "personal folder setup failed: $ferr" ) if $ferr;
    $engine->log( 'info', $user, "personal workspace set to #$ws" );
    return { ok => 1, workspace_id => $ws };
}

sub GET_PERSONAL_ITEMS {
    my ( $self, $kind ) = @_;
    my $user   = $self->get_caller_username();
    my $engine = $self->_engine();
    $self->_require_consent( $engine, $user );
    die "feature disabled\n" if !$engine->cfg->{user_features}{personal_key};
    if    ( ( $kind // '' ) eq 'databases' ) { return { items => $engine->list_user_databases($user) } }
    elsif ( ( $kind // '' ) eq 'email' )     { return { items => $engine->list_user_emails($user) } }
    die "invalid kind\n";
}

sub DELETE_PERSONAL_JOB {
    my ( $self, $job_id ) = @_;
    my $user   = $self->get_caller_username();
    my $engine = $self->_engine();
    $self->_require_consent( $engine, $user );
    die "feature disabled\n" if !$engine->cfg->{user_features}{personal_key};
    $job_id = int( $job_id // 0 );
    my $j = $engine->db->get_user_job_by_id( $job_id, $user );
    die "job not found\n" if !$j;
    $engine->db->delete_user_job( $job_id, $user );
    my $purged = $engine->db->dbh->do(
        q{DELETE FROM jobs WHERE username=? AND origin='personal' AND status='queued'
          AND ( payload LIKE ? OR payload LIKE ? )},
        undef, $user, '%"ujob_id":' . $job_id . ',%', '%"ujob_id":' . $job_id . '}%'
    );
    $purged = 0 if !$purged || $purged eq '0E0';
    $engine->log( 'info', $user,
        $engine->L( 'زمان‌بندی شخصی #%d توسط کاربر حذف شد (%d کار در صف پاک شد)',
            'personal schedule #%d deleted by the user (%d queued job(s) purged)', $job_id, $purged ) );
    return { ok => 1, purged => $purged };
}

sub SET_PERSONAL_JOB {
    my ( $self, $enabled, $btype, $freq, $hour, $minute, $dow, $dom, $retention, $items_json, $job_id, $excludes_json ) = @_;
    my $user   = $self->get_caller_username();
    my $engine = $self->_engine();
    $self->_require_consent( $engine, $user );
    my $cfg    = $engine->cfg;
    my $db     = $engine->db;

    die "feature disabled\n" if !$cfg->{user_features}{personal_key};
    my $tok = $db->get_user_token($user);
    die "ابتدا کلید API شخصی Files.ir خود را ثبت کنید.\n" if !$tok || !$tok->{enabled} || !$tok->{token};

    $enabled = $enabled ? 1 : 0;
    $btype = { full => 'full', databases => 'databases', email => 'email' }->{ $btype // '' }
      or die "invalid backup type\n";
    $freq = { daily => 'daily', weekly => 'weekly', monthly => 'monthly' }->{ $freq // '' }
      or die "invalid frequency\n";
    die "این بازه زمانی توسط مدیر سرور مجاز نشده است.\n" if !$cfg->{personal_freqs}{$freq};

    $hour   = int( $hour   // 0 ); die "invalid hour\n"   if $hour < 0   || $hour > 23;
    $minute = int( $minute // 0 ); die "invalid minute\n" if $minute < 0 || $minute > 55 || $minute % 5;
    $dow    = int( $dow    // 0 ); die "invalid weekday\n" if $dow < 0   || $dow > 6;
    $dom    = int( $dom    // 1 ); die "invalid day\n"     if $dom < 1   || $dom > 28;
    $retention = int( $retention // 7 );
    $retention = 1  if $retention < 1;
    $retention = 90 if $retention > 90;

    my %sched = ( type => $freq, hours => [$hour], minute => $minute );
    $sched{dows} = [$dow] if $freq eq 'weekly';
    $sched{doms} = [$dom] if $freq eq 'monthly';

    my @items;
    if ( $btype ne 'full' && defined $items_json && length $items_json ) {
        my $sel = eval { FilesIR::Util::json_decode($items_json) };
        $sel = [] if ref $sel ne 'ARRAY';
        my %valid = map { $_ => 1 }
          @{ $btype eq 'databases' ? $engine->list_user_databases($user) : $engine->list_user_emails($user) };
        @items = grep { $valid{$_} } map { "$_" } @$sel;
        die "هیچ‌کدام از موارد انتخابی معتبر نیست.\n" if @$sel && !@items;
    }

    my @exc;
    if ( defined $excludes_json && length $excludes_json ) {
        my $ex = eval { FilesIR::Util::json_decode($excludes_json) };
        $ex = [] if ref $ex ne 'ARRAY';
        @exc = grep { length && length() <= 120 && !m{[\r\n\0]} && !m{\.\.} }
          map { my $x = "$_"; $x =~ s/^\s+|\s+$//g; $x } @$ex;
        splice @exc, 40 if @exc > 40;
    }

    $job_id = int( $job_id // 0 );
    if ($job_id) {
        die "job not found\n" if !$db->get_user_job_by_id( $job_id, $user );
    }
    else {
        my $max = int( $cfg->{personal_max_jobs} // 1 );
        die "به سقف تعداد زمان‌بندی‌های شخصی رسیده‌اید (حداکثر $max).\n"
          if $db->count_user_jobs($user) >= $max;
    }
    $job_id = $db->set_user_job( $user,
        job_id => $job_id,
        enabled => $enabled, btype => $btype, sched => \%sched,
        items => \@items, retention_days => $retention, excludes => \@exc );
    $db->log( 'info', $user,
        $enabled
        ? "personal job saved: $btype / $freq @" . sprintf( '%02d:%02d', $hour, $minute )
        : 'personal job disabled' );
    return { saved => 1, enabled => $enabled, job_id => $job_id };
}

sub SET_PERSONAL_TOKEN {
    my ( $self, $token ) = @_;
    my $user   = $self->get_caller_username();
    my $engine = $self->_engine();
    $self->_require_consent( $engine, $user );
    my $cfg    = $engine->cfg;
    die "feature disabled\n" if !$cfg->{user_features}{personal_key};
    $token = defined $token ? "$token" : '';
    $token =~ s/\s+//g;
    die "empty token\n" if !length $token;
    die "token too long\n" if length $token > 512;

    # validate against Files.ir before saving
    my $papi = FilesIR::API->new(
        api_base  => $cfg->{api_base},
        site_base => $cfg->{site_base},
        token     => $token,
    );
    my ( $usage, $err ) = $papi->space_usage();
    die "Files.ir rejected this token: $err\n" if $err;

    $engine->db->set_user_token( $user, $token );
    $engine->db->set_user_workspace( $user, 0 );
    my ( $wss, undef ) = $self->_ws_list($papi);
    my $fresh = $engine->personal_api($user);
    my ( undef, $ferr ) = $engine->ensure_personal_root( $fresh, $user );
    $engine->log( 'warn', $user, "personal folder setup failed: $ferr" ) if $ferr;
    $engine->log( 'info', $user, 'personal Files.ir token saved' );
    return {
        ok      => 1,
        workspaces   => ( ref $wss eq 'ARRAY' ? $wss : [] ),
        workspace_id => 0,
        used_h  => FilesIR::Util::human_bytes( $usage->{used} // 0 ),
        total_h => defined $usage->{available} ? FilesIR::Util::human_bytes( $usage->{available} ) : 'Unlimited',
    };
}

sub REMOVE_PERSONAL_TOKEN {
    my ($self) = @_;
    my $user   = $self->get_caller_username();
    my $engine = $self->_engine();
    $self->_require_consent( $engine, $user );
    $engine->db->delete_user_token($user);
    $engine->log( 'info', $user, 'personal Files.ir token removed' );
    return { ok => 1 };
}

sub LIST_BACKUPS {
    my ($self) = @_;
    my $user   = $self->get_caller_username();
    my $engine = $self->_engine();
    $self->_require_consent( $engine, $user );
    my $cfg    = $engine->cfg;
    my $view_all      = $cfg->{user_features}{view} ? 1 : 0;
    my $personal_only = ( !$view_all && $cfg->{user_features}{personal_key} ) ? 1 : 0;
    die "feature disabled\n" if !$view_all && !$personal_only;

    my $list = $engine->db->list_backups( $user, 100 );
    $list = [ grep { $_->{personal} } @$list ] if $personal_only;
    my $srv_ok = ( $cfg->{user_features}{server_backups} // 1 ) ? 1 : 0;
    $list = [ grep { $_->{personal} } @$list ] if !$srv_ok;
    my $man_ok = ( $cfg->{user_features}{manual_backups} // 0 ) ? 1 : 0;
    $list = [ grep { $_->{personal} || $_->{bjob_id} || $man_ok } @$list ];
    my %hidden_job = map { $_->{id} => 1 }
      grep { !( $_->{visible} // 1 ) } @{ $engine->db->list_bjobs() };
    $list = [ grep { $_->{personal} || !$_->{bjob_id} || !$hidden_job{ $_->{bjob_id} } } @$list ];
    my @out;
    for my $b (@$list) {
        push @out,
          {
            id         => $b->{id},
            name       => $b->{name},
            size       => $b->{size},
            size_h     => FilesIR::Util::human_bytes( $b->{size} ),
            btype      => ( $b->{btype} || 'full' ),
            method     => ( $b->{method} || 'compressed' ),
            bjob_id    => ( $b->{bjob_id} || 0 ),
            personal   => ( $b->{personal} ? 1 : 0 ),
            parts      => ( $b->{parts} || 0 ),
            synced     => ( $b->{synced} ? 1 : 0 ),
            can_restore => (
                ( ( ( $b->{btype} || 'full' ) eq 'full' ) || ( ( $b->{method} || '' ) eq 'incremental' ) )
                  && ( $b->{personal}
                    ? $cfg->{user_features}{personal_restore}
                    : $cfg->{user_features}{restore} ) ? 1 : 0
            ),
            can_link    => (
                ( $b->{personal} || ( $b->{dest_type} || 'filesir' ) eq 'filesir' ) ? 1
                : ( ( $b->{dest_type} || '' ) eq 'local' )
                ? ( ( $cfg->{link_docroot} && -d $cfg->{link_docroot} ) ? 1 : 0 )
                : 0
            ),
            created_at => $b->{created_at},
            date_fa    => FilesIR::Util::jalali_date( $b->{created_at} ),
            date_en    => FilesIR::Util::ts_human( $b->{created_at} ),
          };
    }
    return \@out;
}

sub REQUEST_RESTORE {
    my ( $self, $backup_id ) = @_;
    my $user   = $self->get_caller_username();
    my $engine = $self->_engine();
    $self->_require_consent( $engine, $user );
    my $cfg    = $engine->cfg;
    my $db     = $engine->db;

    die "module disabled\n" if !$cfg->{enabled};

    $backup_id = int( $backup_id // 0 );
    my $b = $db->get_backup($backup_id);
    die "backup not found\n" if !$b || $b->{status} ne 'ok' || $b->{username} ne $user;
    die "backup not found\n" if _job_hidden_from_user( $db, $b, $cfg );
    if ( $b->{personal} ) {
        die "feature disabled\n" if !$cfg->{user_features}{personal_restore};
    }
    else {
        die "feature disabled\n" if !$cfg->{user_features}{restore};
    }
    die "only full backups can be restored\n"
      if ( $b->{btype} || 'full' ) ne 'full' && ( $b->{method} || '' ) ne 'incremental';

    my $limit = int( $cfg->{req_daily_limit} // 0 );
    if ( $limit > 0 ) {
        my $since = time() - 86400;
        my $n = $cfg->{restore_mode} eq 'direct'
          ? $db->count_jobs_since( $user, 'restore', 'user', $since )
          : $db->count_requests_since( $user, $since );
        if ( $n >= $limit ) {
            $db->log( 'warn', $user, "restore request blocked: daily limit reached ($n/$limit)" );
            die "سقف درخواست بازگردانی روزانه شما پر شده است (حداکثر $limit درخواست در ۲۴ ساعت). لطفاً بعداً دوباره تلاش کنید.\n";
        }
    }

    if ( $cfg->{restore_mode} eq 'direct' ) {
        die "restore already active\n" if $db->has_active_job( 'restore', $user );
        my $jid = $db->enqueue_job(
            type      => 'restore',
            username  => $user,
            backup_id => $b->{id},
            origin    => 'user',
        );
        $db->log( 'info', $user, "user started direct restore of backup #$b->{id} (job #$jid)" );
        FilesIR::Engine::spawn_engine();
        return { mode => 'direct', job_id => $jid };
    }

    die "duplicate request\n" if $db->pending_request_for( $user, $b->{id} );
    my $rid = $db->add_request( $user, $b->{id} );
    $db->log( 'info', $user, "restore request submitted for backup #$b->{id} (request #$rid)" );
    eval {
        $engine->notify_admin( 'request_new', "New restore request from $user",
            "Server: " . FilesIR::Util::short_hostname()
              . "\nAccount: $user\nBackup: #$b->{id} ($b->{name})\nRequest: #$rid"
              . "\nApprove or deny it from WHM -> Files.ir -> Restore Requests." );
    };
    return { mode => 'approval', request_id => $rid };
}

sub GET_LINK {
    my ( $self, $backup_id ) = @_;
    my $user   = $self->get_caller_username();
    my $engine = $self->_engine();
    $self->_require_consent( $engine, $user );
    my $cfg    = $engine->cfg;

    die "feature disabled\n" if !$cfg->{user_features}{download_link};

    $backup_id = int( $backup_id // 0 );
    my $b = $engine->db->get_backup($backup_id);
    die "backup not found\n" if !$b || $b->{status} ne 'ok' || $b->{username} ne $user;
    die "backup not found\n" if _job_hidden_from_user( $engine->db, $b, $cfg );

    if ( !$b->{personal} && ( ( $b->{dest_type} || '' ) eq 'local' || ( $b->{method} || '' ) eq 'incremental' ) ) {
        my $hours = $cfg->{link_expiry_hours} || 24;
        my $live  = $engine->db->live_link_for( $b->{id} );
        if ($live) {
            my $left = int( ( $live->{expires_at} - time() ) / 3600 );
            $left = 1 if $left < 1;
            return { url => $live->{url}, expires_hours => $left, incremental => 1 };
        }
        die "تنظیمات لینک دانلود لوکال (مسیر وب و آدرس پایه) در WHM کامل نشده است.\n"
          if !( $cfg->{link_docroot} && -d $cfg->{link_docroot} );
        my $act = $engine->db->active_export_for( $b->{id} );
        if ( !$act ) {
            $engine->db->enqueue_job(
                type => 'export', username => $user, backup_id => $b->{id},
                origin => 'user', payload => { mode => 'link', expiry_hours => $hours },
            );
            FilesIR::Engine::spawn_engine();
            $engine->db->log( 'info', $user, "download-link build queued for backup #$b->{id}", 'sys' );
        }
        return {
            building      => 1,
            expires_hours => $hours,
            message       =>
              "لینک در حال ساخت است؛ وضعیت و دریافت لینک را از تب «لاگ» دنبال کنید. پس از آماده‌شدن، لینک $hours ساعت اعتبار دارد.",
        };
    }

    my ( $link, $err ) = $engine->share_link_for_backup( $b->{id} );
    die "$err\n" if $err;
    my ($bd, $bhm) = ( FilesIR::Util::jalali_date( $b->{created_at} ), _hm( $b->{created_at} ) );
    $bd =~ s/\s.*//;    # date part only
    $engine->db->log( 'info', $user,
        "download link created for backup #$b->{id} ($b->{btype} of $bd)",
        'dl',
        FilesIR::Util::json_encode( {
            url => $link->{url}, backup_id => $b->{id}, btype => $b->{btype},
            backup_d => $bd, backup_hm => $bhm, personal => ( $b->{personal} ? 1 : 0 ),
        } ) );
    return { url => $link->{url}, expires_hours => $cfg->{link_expiry_hours} };
}

# queue + history for this user, split into server-side and personal buckets.
# each item: kind, status, when (job created), backup_date (the copy involved),
# btype, note.
# one-off "backup wizard": run a personal backup right now into the user's
# Files.ir account under cpanel-backup/backupwizard/<stamp>.
sub RUN_PERSONAL_NOW {
    my ( $self, $btype, $items_json ) = @_;
    my $user   = $self->get_caller_username();
    my $engine = $self->_engine();
    $self->_require_consent( $engine, $user );
    my $cfg = $engine->cfg;
    die "feature disabled\n" if !$cfg->{user_features}{personal_key};
    die "feature disabled\n" if !$cfg->{user_features}{backup_wizard};
    die "no personal Files.ir token saved\n" if !$engine->db->get_user_token($user);

    $btype = ( $btype // 'full' );
    die "invalid backup type\n" if $btype !~ /\A(full|databases|email)\z/;
    my @items;
    if ( $btype ne 'full' ) {
        my $raw = eval { FilesIR::Util::json_decode( $items_json || '[]' ) } || [];
        @items = grep { defined && length } @$raw;
        die "هیچ‌کدام از موارد انتخابی معتبر نیست.\n" if !@items;
        my $valid = $btype eq 'databases'
          ? $engine->list_user_databases($user)
          : $engine->list_user_emails($user);
        my %ok = map { $_ => 1 } @$valid;
        for my $it (@items) { die "هیچ‌کدام از موارد انتخابی معتبر نیست.\n" if !$ok{$it} }
    }
    die "backup already running for this account\n"
      if $engine->db->has_active_job( 'backup', $user );

    my $jid = $engine->db->enqueue_job(
        type => 'backup', username => $user, btype => $btype,
        origin => 'personal', dest_id => 0,
        payload => { wizard => 1, ( @items ? ( items => \@items ) : () ) },
    );
    FilesIR::Engine::spawn_engine();
    $engine->log( 'info', $user,
        $engine->L( 'ویزارد بکاپ: یک نسخه فوری %s به صف رفت (کار #%d)',
            'backup wizard: one-off %s backup queued (job #%d)', $btype, $jid ) );
    return { queued => 1, job_id => $jid };
}

sub LIST_MY_ACTIVITY {
    my ($self) = @_;
    my $user   = $self->get_caller_username();
    my $engine = $self->_engine();
    $self->_require_consent( $engine, $user );
    my $db = $engine->db;

    my $jobs = $db->dbh->selectall_arrayref(
        q{SELECT * FROM jobs WHERE username=?
          AND ( type IN ('backup','restore')
                OR ( type='export' AND status IN ('queued','running','failed')
                     AND payload LIKE '%"mode":"link"%' ) )
          ORDER BY id DESC LIMIT 60},
        { Slice => {} }, $user );

    my $srv_ok = ( $engine->cfg->{user_features}{server_backups} // 1 ) ? 1 : 0;
    my %hidden_job = map { $_->{id} => 1 }
      grep { !( $_->{visible} // 1 ) } @{ $db->list_bjobs() };
    my $hidden_backup = sub {
        my ($bk) = @_;
        return 0 if !$bk || $bk->{personal};
        return 1 if !$srv_ok;
        return ( $bk->{bjob_id} && $hidden_job{ $bk->{bjob_id} } ) ? 1 : 0;
    };

    my ( @srv, @per );
    for my $j (@$jobs) {
        next if !$srv_ok && ( $j->{origin} // '' ) ne 'personal' && !( $j->{type} eq 'restore' && $j->{backup_id} && do { my $bk = $db->get_backup( $j->{backup_id} ); $bk && $bk->{personal} } );
        next if $j->{bjob_id} && $hidden_job{ $j->{bjob_id} };
        my $b = $j->{backup_id} ? $db->get_backup( $j->{backup_id} ) : undef;
        next if $hidden_backup->($b);
        my $item = {
            kind        => ( $j->{type} eq 'export' ? 'download' : $j->{type} ),
            status      => $j->{status},
            when        => $j->{created_at},
            finished    => $j->{finished_at},
            btype       => ( $b ? $b->{btype} : ( $j->{btype} || 'full' ) ),
            backup_date => ( $b ? $b->{created_at} : 0 ),
            backup_n    => ( $b ? $b->{id} : 0 ),
            pct         => ( $j->{pct} || 0 ),
            stage       => ( $j->{stage} // '' ),
            note        => substr( $j->{message} // '', 0, 160 ),
        };
        $item->{when_h}    = FilesIR::Util::jalali_date( $item->{when} );
        $item->{when_hm}   = _hm( $item->{when} );
        $item->{fin_h}     = $item->{finished} ? FilesIR::Util::jalali_date( $item->{finished} ) : '';
        $item->{fin_hm}    = $item->{finished} ? _hm( $item->{finished} ) : '';
        $item->{backup_h}  = $item->{backup_date} ? FilesIR::Util::jalali_date( $item->{backup_date} ) : '';
        $item->{backup_hm} = $item->{backup_date} ? _hm( $item->{backup_date} ) : '';
        @{$item}{qw(title_fa title_en)} = _act_titles($item);
        my $jpl0 = eval { FilesIR::Util::json_decode( $j->{payload} || '{}' ) } || {};
        if ( $jpl0->{self} ) {
            my $sk = ( $jpl0->{src_kind} // '' ) eq 'url' ? { fa => 'لینک', en => 'URL' } : { fa => 'فایل', en => 'file' };
            my $pn = $jpl0->{panel} // 'cpanel';
            if ( $pn eq 'cpanel' ) {
                @{$item}{qw(title_fa title_en)} = (
                    "بازگردانی از آرشیو کاربر (منبع: $sk->{fa})",
                    "Restore from a user-supplied archive (source: $sk->{en})",
                );
            }
            else {
                my $pl = $pn eq 'directadmin' ? 'DirectAdmin' : 'Plesk';
                @{$item}{qw(title_fa title_en)} = (
                    "تبدیل بکاپ $pl به cPanel (منبع: $sk->{fa})",
                    "Convert a $pl backup to cPanel (source: $sk->{en})",
                );
            }
        }
        my $is_per = ( ( $j->{origin} // '' ) eq 'personal' || ( $b && $b->{personal} ) ) ? 1 : 0;
        my $side   = $is_per ? 'per' : 'srv';
        my $lkey;
        if ( $item->{kind} eq 'backup' ) {
            if ($is_per) {
                my $jpl = eval { FilesIR::Util::json_decode( $j->{payload} || '{}' ) } || {};
                $lkey = $jpl->{wizard} ? 'wizard' : 'job';
            }
            else { $lkey = $j->{bjob_id} ? 'job' : 'manual' }
        }
        else { $lkey = $item->{kind} eq 'restore' ? 'restore' : 'download' }
        next if !_cp_log_allows( $engine->cfg, $side, $lkey );
        if ($is_per) { push @per, $item } else { push @srv, $item }
    }

    my $reqs = $db->dbh->selectall_arrayref(
        q{SELECT * FROM restore_requests WHERE username=? AND status='pending'
          ORDER BY id DESC LIMIT 10},
        { Slice => {} }, $user );
    for my $r (@$reqs) {
        my $b = $r->{backup_id} ? $db->get_backup( $r->{backup_id} ) : undef;
        my $ri = {
            kind      => 'restore',
            status    => 'pending',
            when      => $r->{created_at},
            when_h    => FilesIR::Util::jalali_date( $r->{created_at} ),
            when_hm   => _hm( $r->{created_at} ),
            btype     => ( $b ? $b->{btype} : 'full' ),
            backup_n  => ( $b ? $b->{id} : 0 ),
            backup_h  => ( $b ? FilesIR::Util::jalali_date( $b->{created_at} ) : '' ),
            backup_hm => ( $b ? _hm( $b->{created_at} ) : '' ),
            pct       => 0,
            stage     => 'pending',
            note      => '',
        };
        if ( my $rb = $db->get_backup( $ri->{backup_n} ) ) {
            $ri->{backup_h}  = FilesIR::Util::jalali_date( $rb->{created_at} );
            $ri->{backup_hm} = _hm( $rb->{created_at} );
        }
        @{$ri}{qw(title_fa title_en)} = _act_titles($ri);
        next if !_cp_log_allows( $engine->cfg, 'srv', 'restore' );
        unshift @srv, $ri;
    }

    my $dls = $db->dbh->selectall_arrayref(
        q{SELECT * FROM logs WHERE username=? AND tag='dl'
          ORDER BY id DESC LIMIT 30},
        { Slice => {} }, $user );
    my @dl;
    for my $d (@$dls) {
        my $m = $d->{meta} ? eval { FilesIR::Util::json_decode( $d->{meta} ) } : undef;
        $m ||= {};
        if ( !$m->{backup_id} && $d->{message} =~ /#(\d+)/ ) { $m->{backup_id} = $1 }
        if ( $m->{backup_id} && !$m->{personal} ) {
            my $bk = $db->get_backup( $m->{backup_id} );
            next if $hidden_backup->($bk);
        }
        my $item = {
            kind      => 'download',
            status    => 'done',
            when      => $d->{created_at},
            when_h    => FilesIR::Util::jalali_date( $d->{created_at} ),
            when_hm   => _hm( $d->{created_at} ),
            finished  => $d->{created_at},
            btype     => ( $m->{btype} || 'full' ),
            backup_n  => ( $m->{backup_id} || 0 ),
            backup_h  => ( $m->{backup_d} || '' ),
            backup_hm => ( $m->{backup_hm} || '' ),
            url       => ( $m->{url} || '' ),
            expires_h => ( $m->{expires_h} || 0 ),
            pct       => 100,
            stage     => 'done',
            note      => ( $d->{message} // '' ),
        };
        @{$item}{qw(title_fa title_en)} = _act_titles($item);
        next if !_cp_log_allows( $engine->cfg, ( $m->{personal} ? 'per' : 'srv' ), 'download' );
        if ( $m->{personal} ) { push @per, $item } else { push @srv, $item }
        push @dl, $item;
    }
    @srv = sort { ( $b->{when} || 0 ) <=> ( $a->{when} || 0 ) } @srv;
    @per = sort { ( $b->{when} || 0 ) <=> ( $a->{when} || 0 ) } @per;

    return { server => \@srv, personal => \@per, downloads => \@dl };
}

sub LIST_REQUESTS {
    my ($self) = @_;
    my $user   = $self->get_caller_username();
    my $engine = $self->_engine();
    $self->_require_consent( $engine, $user );

    my $list = $engine->db->list_requests( $user, 30 );
    my @out;
    for my $r (@$list) {
        push @out,
          {
            id        => $r->{id},
            backup_id => $r->{backup_id},
            status    => $r->{status},
            date_fa   => FilesIR::Util::jalali_date( $r->{created_at} ),
            date_en   => FilesIR::Util::ts_human( $r->{created_at} ),
          };
    }
    return \@out;
}

1;
