package Whostmgr::CometBackup::BackupJobs;

#                                      Copyright 2025 WebPros International, LLC
#                                                           All rights reserved.
# copyright@cpanel.net                                         http://cpanel.net
# This code is subject to the cPanel license. Unauthorized copying is prohibited.

=encoding utf-8

=head1 NAME

Whostmgr::CometBackup::BackupJobs - Backup job management system for Comet backup integration

=head1 SYNOPSIS

    use Whostmgr::CometBackup::BackupJobs ();

    my $jobs = Whostmgr::CometBackup::BackupJobs->new();

    # Create a new backup job
    my $job_uuid = $jobs->create_backup_job(
        name          => 'Daily Backup',
        description   => 'Daily backup of all accounts',
        schedule_type => 'scheduled',
        schedule_info => {
            frequency => 'daily',
            when      => [1, 2, 3, 4, 5], # weekdays
        },
        backup_options => {
            panel_config  => 1,
            databases     => {
                mysql    => 1,
                postgresql => 1,
            },
            database_users => 1,
            email_accounts => {
                email_contents => 1,
                exclude_junk   => 1,
            },
            cron_jobs    => 1,
            domains       => 1,
            ssl_certs    => 1,
        },
        all_accounts       => 1,
        accounts           => [],
        custom_exclusions  => [],
        storage_type       => 'cloud',
        run_now           => 0,
    );

    # Get all backup jobs
    my @all_jobs = $jobs->get_backup_jobs();

    # Get specific backup jobs by ID
    my @specific_jobs = $jobs->get_backup_jobs([$job_uuid]);

    # Disable a backup job (soft delete)
    $jobs->disable_backup_job($job_uuid);

    # Delete a backup job (permanently remove from database)
    $jobs->delete_backup_job($job_uuid);

=head1 DESCRIPTION

This module provides a SQLite-based storage system for managing Comet backup jobs.
It handles job creation, retrieval, updating, and deletion operations with proper
data validation and UUID generation for each job.

The module uses an SQLite database at the path specified by
C<$Whostmgr::CometBackup::Constants::BACKUP_JOBS_DB> to store backup job configurations
and metadata.

=cut

use cPstrict;

use Cpanel::DBI::SQLite               ();
use Cpanel::Exception                 ();
use Cpanel::FileUtils::TouchFile      ();
use Cpanel::JSON                      ();
use Cpanel::Mkdir                     ();
use Cpanel::UUID                      ();
use Cpanel::Validate::FilesystemPath  ();
use Cpanel::Validate::UUID            ();
use DBD::SQLite                       ();
use Time::Piece                       ();
use Time::Seconds                     ();
use Whostmgr::CometBackup::Constants  ();
use Whostmgr::CometBackup::Scheduling ();
use Whostmgr::CometBackup::Logger;

my $logger = Whostmgr::CometBackup::Logger->new();

# Recursion guard for status check
our $IN_STATUS_CHECK = 0;

=head1 METHODS

=head2 new

    my $jobs = Whostmgr::CometBackup::BackupJobs->new();

Creates a new BackupJobs instance and initializes the database if needed.

=cut

sub new ($class) {
    my $self = bless {}, $class;
    $self->_ensure_database();
    return $self;
}

=head2 create_backup_job

    my $job_uuid = $jobs->create_backup_job(
        name          => 'Daily Backup',
        description   => 'Daily backup of all accounts',
        schedule_type => 'scheduled',
        schedule_info => {
            frequency => 'daily',
            when      => [1, 2, 3, 4, 5],  # weekdays
            time      => '02:00',          # 2 AM
        },
        backup_options => {
            panel_config  => 1,
            databases     => {
                mysql    => 1,
                postgresql => 1,
            },
            database_users => 1,
            email_accounts => {
                email_contents => 1,
                exclude_junk   => 1,
            },
            cron_jobs    => 1,
            domains       => 1,
            ssl_certs    => 1,
            homedir       => {
                exclude_regex => ['.*/cache/.*', '.*/tmp/.*', '.*\\.cpanel/.*']
            },
        },
        all_accounts       => 1,
        accounts           => [],
        custom_exclusions  => [],
        destinations       => [ '331f5ec2-f836-441a-947c-4005d815c433' ],  # Array of destination IDs from Comet profile
        local_path         => '/backup/',  # Optional, only for legacy 'local' destinations (defaults to '/backup/')
        run_now            => 0,
    );

Creates a new backup job with the specified configuration.

Parameters:
- name: The name of the backup job (required)
- description: Description of the backup job (optional)
- schedule_type: Either 'scheduled' or 'manual' (required)
- schedule_info: Hash reference with scheduling information (required for scheduled jobs)
  - frequency: 'daily', 'weekly', or 'monthly' (required)
  - when: Array of days (1-7 for weekdays, 1-31 for monthly days) (optional for daily)
  - time: Time in HH:MM format (optional, defaults to '02:00')
- backup_options: Hash reference with backup configuration options (required)
- all_accounts: Boolean indicating if all accounts should be backed up (required)
- accounts: Array reference of specific accounts to backup (optional)
- custom_exclusions: Array reference of custom exclusion patterns (optional)
- destinations: Array reference of storage destination strings (required)
  - Can be destination UUIDs from Comet profile, or legacy values 'cloud'/'local' for backwards compatibility
  - Example: [ '331f5ec2-f836-441a-947c-4005d815c433' ] or [ 'cloud' ], [ 'local' ]
- local_path: Storage path for local destinations (optional, defaults to '/backup/', only used with legacy 'local' destination)
- run_now: Boolean indicating if the job should run immediately (optional, defaults to 0)

Returns: UUID of the created backup job

=cut

sub create_backup_job ( $self, %args ) {
    my $name           = $args{name}           or die "name parameter required";
    my $schedule_type  = $args{schedule_type}  or die "schedule_type parameter required";
    my $backup_options = $args{backup_options} or die "backup_options parameter required";

    # Sanitize text input fields to prevent injection attacks
    $name = sanitize_text_input( $name, 'name' );
    if ( $args{description} ) {
        $args{description} = sanitize_text_input( $args{description}, 'description' );
    }

    # Support both old (storage_type) and new (destinations) parameter formats
    my $destinations = $args{destinations};

    # Backward compatibility: convert old storage_type parameter to destinations array
    if ( !$destinations && $args{storage_type} ) {
        $destinations = [ $args{storage_type} ];
    }

    die "destinations parameter required" unless $destinations;

    # Validate schedule_type
    unless ( $schedule_type eq 'scheduled' || $schedule_type eq 'manual' ) {
        die "schedule_type must be 'scheduled' or 'manual'";
    }

    # Validate destinations - must be array of strings
    unless ( ref $destinations eq 'ARRAY' && @$destinations > 0 ) {
        die "destinations must be a non-empty array reference";
    }

    # Validate each destination is a string and convert to internal format
    my @normalized_destinations;
    for my $dest (@$destinations) {

        # Destination must be a simple string (not a hash or other ref)
        if ( ref $dest ) {
            die "each destination must be a string, not a reference";
        }

        # Convert to internal hash format
        my $dest_hash = {
            id           => $dest,
            storage_type => $dest,
        };

        # Set default storage_path for local destinations (backwards compatibility)
        # If destination is exactly 'local', use the legacy local_path parameter
        if ( $dest eq 'local' ) {
            $dest_hash->{storage_path} = $args{local_path} || '/backup/';
            my $storage_path = $dest_hash->{storage_path};
            unless ( Cpanel::Validate::FilesystemPath::is_valid($storage_path) ) {
                die "destination storage_path must be a valid filesystem path";
            }
        }

        push @normalized_destinations, $dest_hash;
    }

    # Replace destinations with normalized version
    $destinations = \@normalized_destinations;

    # Validate schedule_info for scheduled jobs
    if ( $schedule_type eq 'scheduled' ) {
        my $schedule_info = $args{schedule_info} or die "schedule_info required for scheduled jobs";
        unless ( ref $schedule_info eq 'HASH' ) {
            die "schedule_info must be a hash reference";
        }
        unless ( $schedule_info->{frequency} ) {
            die "schedule_info must contain frequency";
        }
    }

    # Validate backup_options structure
    unless ( ref $backup_options eq 'HASH' ) {
        die "backup_options must be a hash reference";
    }

    # Normalize backup_options (e.g., convert databases: 1 to {mysql: 1, postgresql: 1})
    $self->_normalize_backup_options($backup_options);

    # Validate backup_options fields
    $self->_validate_backup_options($backup_options);

    # Validate all_accounts parameter
    my $all_accounts = exists $args{all_accounts} ? $args{all_accounts} : die "all_accounts parameter required";
    unless ( $all_accounts == 0 || $all_accounts == 1 ) {
        die "all_accounts must be 0 or 1";
    }

    # Sanitize accounts array
    if ( $args{accounts} && ref $args{accounts} eq 'ARRAY' ) {
        $args{accounts} = [ map { sanitize_text_input( $_, 'accounts' ) } @{ $args{accounts} } ];
    }

    # Sanitize custom_exclusions array
    if ( $args{custom_exclusions} && ref $args{custom_exclusions} eq 'ARRAY' ) {
        $args{custom_exclusions} = [ map { sanitize_text_input( $_, 'custom_exclusions' ) } @{ $args{custom_exclusions} } ];
    }

    # Generate UUID for the job
    my $job_uuid = Cpanel::UUID::random_uuid();

    # Prepare data for storage
    # Note: run_now is NOT stored in the database - it's only used to trigger immediate execution
    my $job_data = {
        name              => $name,
        description       => $args{description} || '',
        schedule_type     => $schedule_type,
        schedule_info     => $args{schedule_info} || {},
        backup_options    => $backup_options,
        all_accounts      => $all_accounts,
        accounts          => $args{accounts}          || [],
        custom_exclusions => $args{custom_exclusions} || [],
        destinations      => $destinations,
    };

    my $dbh = $self->_get_database_handle();

    my $current_time = time();
    my $sth          = $dbh->prepare(
        q{
        INSERT INTO backup_jobs
        (uuid, name, description, schedule_type, job_config, status, created_timestamp, updated_timestamp, is_active)
        VALUES (?, ?, ?, ?, ?, 'never_run', ?, ?, 1)
    }
    );

    my $job_config_json = Cpanel::JSON::Dump($job_data);
    $sth->execute( $job_uuid, $name, $job_data->{description}, $schedule_type, $job_config_json, $current_time, $current_time );

    return $job_uuid;
}

=head2 get_backup_jobs

    my @jobs = $jobs->get_backup_jobs();
    my @specific_jobs = $jobs->get_backup_jobs(['uuid1', 'uuid2']);
    my @active_jobs = $jobs->get_backup_jobs([], { active_only => 1 });
    my @sorted_jobs = $jobs->get_backup_jobs([], { sort_by => 'name', sort_direction => 'asc' });

Retrieves backup jobs with optional filtering and sorting.

Parameters:
- job_ids: Optional array reference of job UUIDs to retrieve (if not provided or empty, returns all jobs)
- options: Optional hash reference with additional options:
  - active_only: Boolean, if true only returns active jobs (defaults to 0 - returns all jobs)
  - sort_by: String, field to sort by (name, last_run, next_run) (defaults to created_timestamp)
  - sort_direction: String, sort direction (asc, desc) (defaults to desc)

Returns: Array of hash references with job data including status information

=cut

sub get_backup_jobs ( $self, $job_ids = [], $options = {} ) {
    $job_ids ||= [];
    $options ||= {};
    my $active_only    = exists $options->{active_only}    ? $options->{active_only}    : 0;
    my $sort_by        = exists $options->{sort_by}        ? $options->{sort_by}        : '';
    my $sort_direction = exists $options->{sort_direction} ? $options->{sort_direction} : 'desc';

    # Validate job IDs if provided
    if ( $job_ids && @$job_ids ) {
        unless ( ref $job_ids eq 'ARRAY' ) {
            die "job_ids must be an array reference";
        }
        for my $job_id (@$job_ids) {
            Cpanel::Validate::UUID::validate_uuid_or_die( $job_id, 'job_id' );
        }
    }

    # Validate sort_direction
    if ( $sort_direction && $sort_direction !~ /^(asc|desc)$/i ) {
        die "sort_direction must be 'asc' or 'desc'";
    }

    # Validate sort_by if provided
    if ( $sort_by && $sort_by !~ /^(name|last_run|created|next_run|frequency|retain)$/i ) {
        die "sort_by must be one of: name, last_run, created, next_run, frequency, retain";
    }
    my $dbh = $self->_get_database_handle();

    my $sql = q{
        SELECT uuid, name, description, schedule_type, job_config, status,
               last_run_timestamp, current_run_uuid, created_timestamp,
               updated_timestamp, is_active
        FROM backup_jobs
    };

    my @where_conditions;
    my @bind_params;

    if ($active_only) {
        push @where_conditions, 'is_active = 1';
    }

    if ( $job_ids && @$job_ids ) {
        my $placeholders = join( ',', ('?') x @$job_ids );
        push @where_conditions, "uuid IN ($placeholders)";
        push @bind_params,      @$job_ids;
    }

    if (@where_conditions) {
        $sql .= ' WHERE ' . join( ' AND ', @where_conditions );
    }

    # Build ORDER BY clause based on sort parameters
    my $order_by_clause = $self->_build_order_by_clause( $sort_by, $sort_direction );
    $sql .= $order_by_clause;

    my $sth = $dbh->prepare($sql);
    $sth->execute(@bind_params);

    my @results;
    while ( my $row = $sth->fetchrow_hashref() ) {

        # Decode the JSON configuration
        if ( $row->{job_config} ) {
            my $job_config = eval { Cpanel::JSON::Load( $row->{job_config} ) };
            if ($@) {
                warn "Failed to decode job_config JSON for job $row->{uuid}: $@";
                next;
            }

            # Merge the config data into the main row
            $row = { %$row, %$job_config };
            delete $row->{job_config};    # Remove the raw JSON field
        }

        # Add job status information
        $row = $self->_enhance_job_data($row);

        push @results, $row;
    }

    # For fields that are computed (like next_run), sort in Perl after fetching
    if ( $sort_by && ( $sort_by eq 'next_run' || $sort_by eq 'frequency' || $sort_by eq 'retain' ) ) {
        @results = $self->_sort_results_in_memory( \@results, $sort_by, $sort_direction );
    }

    return @results;
}

=head2 update_backup_job

    my $success = $jobs->update_backup_job($job_uuid, %updated_fields);

Updates an existing backup job with new configuration data.

Parameters:
- job_uuid: The UUID of the backup job to update
- Additional parameters: Same as create_backup_job (only provided fields will be updated)

Returns: 1 on success, 0 if job not found

=cut

sub update_backup_job ( $self, $job_uuid, %args ) {
    Cpanel::Validate::UUID::validate_uuid_or_die( $job_uuid, 'job_uuid' );

    # Get existing job to merge with updates
    my @existing_jobs = $self->get_backup_jobs( [$job_uuid] );
    return 0 unless @existing_jobs;
    my $existing_job = $existing_jobs[0];

    # Merge existing data with updates
    my %updated_data = %$existing_job;
    for my $key ( keys %args ) {
        $updated_data{$key} = $args{$key};
    }

    # Remove metadata fields that shouldn't be in job_config
    my %job_config = %updated_data;
    delete $job_config{uuid};
    delete $job_config{created_timestamp};
    delete $job_config{updated_timestamp};
    delete $job_config{is_active};

    # Remove enhanced runtime fields added by _enhance_job_data that shouldn't be persisted
    delete $job_config{job_id};                # Renamed from uuid by _enhance_job_data
    delete $job_config{status};                # Stored in separate status column
    delete $job_config{last_run};              # Renamed from last_run_timestamp by _enhance_job_data
    delete $job_config{last_run_timestamp};    # Stored in separate last_run_timestamp column
    delete $job_config{next_run};              # Calculated dynamically, never stored
    delete $job_config{run_uuid};              # Stored in separate current_run_uuid column

    # Convert enriched destinations back to simple internal format before storing
    # Destinations get enriched by _enhance_job_data with full storage details,
    # but we only want to store the destination IDs in the database
    if ( $job_config{destinations} && ref $job_config{destinations} eq 'ARRAY' ) {
        my @simple_destinations;
        for my $dest ( @{ $job_config{destinations} } ) {
            if ( ref $dest eq 'HASH' && $dest->{id} ) {

                # Convert enriched destination back to simple internal format
                my $simple_dest = {
                    id           => $dest->{id},
                    storage_type => $dest->{id},    # Keep storage_type for backwards compatibility
                };

                # Preserve storage_path if it exists (for local destinations)
                if ( $dest->{storage_path} ) {
                    $simple_dest->{storage_path} = $dest->{storage_path};
                }
                push @simple_destinations, $simple_dest;
            }
            elsif ( !ref $dest ) {

                # Already in simple format (just an ID string)
                push @simple_destinations, {
                    id           => $dest,
                    storage_type => $dest,
                };
            }
        }
        $job_config{destinations} = \@simple_destinations if @simple_destinations;
    }

    my $dbh = $self->_get_database_handle();

    my $current_time = time();
    my $sth          = $dbh->prepare(
        q{
        UPDATE backup_jobs
        SET name = ?, description = ?, schedule_type = ?, job_config = ?, updated_timestamp = ?
        WHERE uuid = ? AND is_active = 1
    }
    );

    my $job_config_json = Cpanel::JSON::Dump( \%job_config );
    my $rows_affected   = $sth->execute(
        $updated_data{name},
        $updated_data{description} || '',
        $updated_data{schedule_type},
        $job_config_json,
        $current_time,
        $job_uuid
    );

    return $rows_affected > 0 ? 1 : 0;
}

=head2 disable_backup_job

    my $success = $jobs->disable_backup_job($job_uuid);

Marks a backup job as inactive (soft delete). The job record is preserved
for audit purposes but marked as inactive.

Parameters:
- job_uuid: The UUID of the backup job to disable

Returns: 1 on success, 0 if job not found

=cut

sub disable_backup_job ( $self, $job_uuid ) {
    Cpanel::Validate::UUID::validate_uuid_or_die( $job_uuid, 'job_uuid' );

    my $dbh = $self->_get_database_handle();

    my $current_time = time();
    my $sth          = $dbh->prepare(
        q{
        UPDATE backup_jobs
        SET is_active = 0, updated_timestamp = ?
        WHERE uuid = ? AND is_active = 1
    }
    );

    my $rows_affected = $sth->execute( $current_time, $job_uuid );

    return $rows_affected > 0 ? 1 : 0;
}

=head2 enable_backup_job

    my $success = $jobs->enable_backup_job($job_uuid);

Marks a backup job as active (re-enables a previously disabled job).
The job record is updated to set is_active to 1.

Parameters:
- job_uuid: The UUID of the backup job to enable

Returns: 1 on success, 0 if job not found

=cut

sub enable_backup_job ( $self, $job_uuid ) {
    Cpanel::Validate::UUID::validate_uuid_or_die( $job_uuid, 'job_uuid' );

    my $dbh = $self->_get_database_handle();

    my $current_time = time();
    my $sth          = $dbh->prepare(
        q{
        UPDATE backup_jobs
        SET is_active = 1, updated_timestamp = ?
        WHERE uuid = ? AND is_active = 0
    }
    );

    my $rows_affected = $sth->execute( $current_time, $job_uuid );

    return $rows_affected > 0 ? 1 : 0;
}

=head2 update_last_run_timestamp

    $jobs->update_last_run_timestamp($job_uuid);
    $jobs->update_last_run_timestamp($job_uuid, $timestamp);

Updates the last_run_timestamp for a backup job. This is typically called when
a backup job completes successfully to track when it last ran for cooldown purposes.

Parameters:
- job_uuid: The UUID of the backup job to update (required)
- timestamp: The Unix timestamp to set (optional, defaults to current time)

Returns: 1 if successful, 0 if job not found

=cut

sub update_last_run_timestamp ( $self, $job_uuid, $timestamp = undef ) {
    Cpanel::Validate::UUID::validate_uuid_or_die( $job_uuid, 'job_uuid' );

    my $dbh = $self->_get_database_handle();

    my $run_time = $timestamp // time();
    my $sth      = $dbh->prepare(
        q{
        UPDATE backup_jobs
        SET last_run_timestamp = ?
        WHERE uuid = ?
    }
    );

    my $rows_affected = $sth->execute( $run_time, $job_uuid );

    return $rows_affected > 0 ? 1 : 0;
}

=head2 update_job_status

    $jobs->update_job_status($job_uuid, 'running');

Updates the status field of a backup job.

Parameters:
- job_uuid: The UUID of the backup job to update (required)
- status: The new status value (required) - typical values: 'pending', 'running', 'completed', 'failed'

Returns: 1 on success, 0 if job not found

=cut

sub update_job_status ( $self, $job_uuid, $status ) {
    Cpanel::Validate::UUID::validate_uuid_or_die( $job_uuid, 'job_uuid' );

    unless ($status) {
        die "status parameter is required";
    }

    my $dbh = $self->_get_database_handle();

    my $sth = $dbh->prepare(
        q{
        UPDATE backup_jobs
        SET status = ?
        WHERE uuid = ?
    }
    );

    my $rows_affected = $sth->execute( $status, $job_uuid );

    return $rows_affected > 0 ? 1 : 0;
}

=head2 set_current_run_uuid

    $jobs->set_current_run_uuid($job_uuid, $run_uuid);

Sets the current_run_uuid for a backup job to track which run is currently executing.
This is called when a backup job starts running.

Parameters:
- job_uuid: The UUID of the backup job (required)
- run_uuid: The UUID of the current backup run (required)

Returns: 1 on success, 0 if job not found

=cut

sub set_current_run_uuid ( $self, $job_uuid, $run_uuid ) {
    Cpanel::Validate::UUID::validate_uuid_or_die( $job_uuid, 'job_uuid' );
    Cpanel::Validate::UUID::validate_uuid_or_die( $run_uuid, 'run_uuid' );

    my $dbh = $self->_get_database_handle();

    my $sth = $dbh->prepare(
        q{
        UPDATE backup_jobs
        SET current_run_uuid = ?
        WHERE uuid = ?
    }
    );

    my $rows_affected = $sth->execute( $run_uuid, $job_uuid );

    return $rows_affected > 0 ? 1 : 0;
}

=head2 clear_current_run_uuid

    $jobs->clear_current_run_uuid($job_uuid);

Clears the current_run_uuid for a backup job when the run completes or fails.
This is called when a backup job finishes running.

Parameters:
- job_uuid: The UUID of the backup job (required)

Returns: 1 on success, 0 if job not found

=cut

sub clear_current_run_uuid ( $self, $job_uuid ) {
    Cpanel::Validate::UUID::validate_uuid_or_die( $job_uuid, 'job_uuid' );

    my $dbh = $self->_get_database_handle();

    my $sth = $dbh->prepare(
        q{
        UPDATE backup_jobs
        SET current_run_uuid = NULL
        WHERE uuid = ?
    }
    );

    my $rows_affected = $sth->execute($job_uuid);

    return $rows_affected > 0 ? 1 : 0;
}

=head2 duplicate_backup_job

    my $new_job_uuid = $jobs->duplicate_backup_job($source_job_uuid, $new_name);

Creates a duplicate of an existing backup job with a new UUID and optionally a new name.
All other settings from the source job are copied to the new job.

Parameters:
- source_job_uuid: The UUID of the backup job to duplicate (required)
- new_name: The name for the new job (optional, if not provided uses source name with " (Copy)" suffix)

Returns: UUID of the new duplicated backup job, or undef if source job not found

=cut

sub duplicate_backup_job ( $self, $source_job_uuid, $new_name = undef ) {
    Cpanel::Validate::UUID::validate_uuid_or_die( $source_job_uuid, 'source_job_uuid' );

    # Sanitize new_name if provided
    if ( defined $new_name ) {
        $new_name = sanitize_text_input( $new_name, 'new_name' );
    }

    # Get the source job to duplicate
    my @source_jobs = $self->get_backup_jobs( [$source_job_uuid], { active_only => 0 } );
    return unless @source_jobs;

    my $source_job = $source_jobs[0];

    # Generate a new UUID for the duplicate
    my $new_job_uuid = Cpanel::UUID::random_uuid();

    # Set the new name - use provided name or create a copy suffix
    my $duplicate_name = $new_name || ( $source_job->{name} . ' (Copy)' );

    # Prepare the job data for the duplicate (copy all settings except metadata)
    my %duplicate_data = %$source_job;

    # Update fields that should be different for the duplicate
    $duplicate_data{name} = $duplicate_name;
    delete $duplicate_data{job_id};                # Remove API field name (uuid in DB)
    delete $duplicate_data{uuid};                  # Will be set to new UUID
    delete $duplicate_data{created_timestamp};     # Will be set to current time
    delete $duplicate_data{updated_timestamp};     # Will be set to current time
    delete $duplicate_data{last_run};              # Remove API field name
    delete $duplicate_data{last_run_timestamp};    # Reset for new job
    delete $duplicate_data{next_run};              # Calculated dynamically
    delete $duplicate_data{status};                # Will default to 'pending'
    $duplicate_data{is_active} = 1;                # New job starts as active

    # Prepare data for storage
    my $job_data = {
        name              => $duplicate_data{name},
        description       => $duplicate_data{description} || '',
        schedule_type     => $duplicate_data{schedule_type},
        schedule_info     => $duplicate_data{schedule_info} || {},
        backup_options    => $duplicate_data{backup_options},
        all_accounts      => $duplicate_data{all_accounts},
        accounts          => $duplicate_data{accounts}          || [],
        custom_exclusions => $duplicate_data{custom_exclusions} || [],
        destinations      => $duplicate_data{destinations}      || [],
        run_now           => 0,    # Don't run immediately by default
    };

    my $dbh = $self->_get_database_handle();

    my $current_time = time();
    my $sth          = $dbh->prepare(
        q{
        INSERT INTO backup_jobs
        (uuid, name, description, schedule_type, job_config, created_timestamp, updated_timestamp, is_active)
        VALUES (?, ?, ?, ?, ?, ?, ?, 1)
    }
    );

    my $job_config_json = Cpanel::JSON::Dump($job_data);
    $sth->execute( $new_job_uuid, $duplicate_name, $job_data->{description}, $duplicate_data{schedule_type}, $job_config_json, $current_time, $current_time );

    return $new_job_uuid;
}

=head2 delete_backup_job

    my $success = $jobs->delete_backup_job($job_uuid);

Permanently removes a backup job from the database and disables all associated
Protected Items by setting their status to 'disabled'.

Parameters:
- job_uuid: The UUID of the backup job to delete

Returns: Number of records removed (backup job count)

=cut

sub delete_backup_job ( $self, $job_uuid ) {
    Cpanel::Validate::UUID::validate_uuid_or_die( $job_uuid, 'job_uuid' );

    # First, disable all Protected Items associated with this backup job
    # Note: Protected Items are in a separate database (protected_items.db)
    require Whostmgr::CometBackup::ProtectedItemMap;
    my $map = Whostmgr::CometBackup::ProtectedItemMap->new();

    # Get the protected_items database handle (not the backup_jobs handle!)
    my $items_dbh = $map->_get_database_handle();

    my $items_sth = $items_dbh->prepare(
        q{
        UPDATE protected_items
        SET status = 'disabled',
            updated_timestamp = ?
        WHERE job_uuid = ?
    }
    );

    my $items_updated = $items_sth->execute( time(), $job_uuid );

    # Log the Protected Items update
    if ( $items_updated && $items_updated > 0 ) {
        require Whostmgr::CometBackup::Logger;
        my $logger = Whostmgr::CometBackup::Logger->new();
        $logger->info(
            "Disabled Protected Items for deleted backup job",
            {
                job_uuid      => $job_uuid,
                items_updated => $items_updated
            }
        );
    }

    # Move the backup job to deleted_backup_jobs table instead of hard delete
    # This preserves job information for historical backup run lookups
    my $dbh = $self->_get_database_handle();

    # First, copy the job to deleted_backup_jobs
    my $copy_sth = $dbh->prepare(
        q{
        INSERT INTO deleted_backup_jobs 
            (uuid, name, description, schedule_type, job_config, status, 
             last_run_timestamp, current_run_uuid, created_timestamp, 
             updated_timestamp, deleted_timestamp, is_active)
        SELECT 
            uuid, name, description, schedule_type, job_config, status, 
            last_run_timestamp, current_run_uuid, created_timestamp, 
            updated_timestamp, ?, 0
        FROM backup_jobs
        WHERE uuid = ?
    }
    );

    my $deleted_timestamp = time();
    my $rows_copied       = $copy_sth->execute( $deleted_timestamp, $job_uuid );

    # If copy succeeded, delete from backup_jobs
    if ( $rows_copied && $rows_copied > 0 ) {
        my $delete_sth = $dbh->prepare(
            q{
            DELETE FROM backup_jobs
            WHERE uuid = ?
        }
        );
        return $delete_sth->execute($job_uuid);
    }

    # If no rows were copied, the job didn't exist
    return 0;
}

=head2 modify_backup_job

    my $success = $jobs->modify_backup_job($job_uuid, %job_config);

Modifies an existing backup job with new configuration data.

Parameters:
- job_uuid: The UUID of the backup job to modify
- %job_config: Hash of job configuration parameters (same as create_backup_job)

Note that a "pending" status on a Protected Item means we local config knows about it, but it has not yet been created
on the remote management endpoit. This should be a small window.

Returns: 1 on success, 0 if job not found

=cut

sub modify_backup_job ( $self, $job_uuid, %args ) {
    Cpanel::Validate::UUID::validate_uuid_or_die( $job_uuid, 'job_uuid' );

    # Get existing job to compare accounts for Protected Item management
    my @existing_jobs = $self->get_backup_jobs( [$job_uuid] );
    unless (@existing_jobs) {
        return 0;    # Job not found
    }
    my $existing_job = $existing_jobs[0];

    my $name           = $args{name}           or die "name parameter required";
    my $schedule_type  = $args{schedule_type}  or die "schedule_type parameter required";
    my $backup_options = $args{backup_options} or die "backup_options parameter required";

    # Sanitize text input fields to prevent injection attacks
    $name = sanitize_text_input( $name, 'name' );
    if ( $args{description} ) {
        $args{description} = sanitize_text_input( $args{description}, 'description' );
    }

    # Support both old (storage_type) and new (destinations) parameter formats
    my $destinations = $args{destinations};

    # Backward compatibility: convert old storage_type parameter to destinations array
    if ( !$destinations && $args{storage_type} ) {
        $destinations = [ $args{storage_type} ];
    }

    die "destinations parameter required" unless $destinations;

    # Validate schedule_type
    unless ( $schedule_type eq 'scheduled' || $schedule_type eq 'manual' ) {
        die "schedule_type must be 'scheduled' or 'manual'";
    }

    # Validate destinations - must be array of strings
    unless ( ref $destinations eq 'ARRAY' && @$destinations > 0 ) {
        die "destinations must be a non-empty array reference";
    }

    # Validate each destination is a string and convert to internal format
    my @normalized_destinations;
    for my $dest (@$destinations) {

        # Destination must be a simple string (not a hash or other ref)
        if ( ref $dest ) {
            die "each destination must be a string, not a reference";
        }

        # Convert to internal hash format
        my $dest_hash = {
            id           => $dest,
            storage_type => $dest,
        };

        # Set default storage_path for local destinations (backwards compatibility)
        # If destination is exactly 'local', use the legacy local_path parameter
        if ( $dest eq 'local' ) {
            $dest_hash->{storage_path} = $args{local_path} || '/backup/';
            my $storage_path = $dest_hash->{storage_path};
            unless ( Cpanel::Validate::FilesystemPath::is_valid($storage_path) ) {
                die "destination storage_path must be a valid filesystem path";
            }
        }

        push @normalized_destinations, $dest_hash;
    }

    # Replace destinations with normalized version
    $destinations = \@normalized_destinations;

    # Validate schedule_info for scheduled jobs
    if ( $schedule_type eq 'scheduled' ) {
        my $schedule_info = $args{schedule_info} or die "schedule_info required for scheduled jobs";
        unless ( ref $schedule_info eq 'HASH' ) {
            die "schedule_info must be a hash reference";
        }
        unless ( $schedule_info->{frequency} ) {
            die "schedule_info must contain frequency";
        }
    }

    # Validate backup_options structure
    unless ( ref $backup_options eq 'HASH' ) {
        die "backup_options must be a hash reference";
    }

    # Normalize backup_options (e.g., convert databases: 1 to {mysql: 1, postgresql: 1})
    $self->_normalize_backup_options($backup_options);

    # Validate backup_options fields
    $self->_validate_backup_options($backup_options);

    # Validate all_accounts parameter
    my $all_accounts = exists $args{all_accounts} ? $args{all_accounts} : die "all_accounts parameter required";
    unless ( $all_accounts == 0 || $all_accounts == 1 ) {
        die "all_accounts must be 0 or 1";
    }

    # Sanitize accounts array
    if ( $args{accounts} && ref $args{accounts} eq 'ARRAY' ) {
        $args{accounts} = [ map { sanitize_text_input( $_, 'accounts' ) } @{ $args{accounts} } ];
    }

    # Sanitize custom_exclusions array
    if ( $args{custom_exclusions} && ref $args{custom_exclusions} eq 'ARRAY' ) {
        $args{custom_exclusions} = [ map { sanitize_text_input( $_, 'custom_exclusions' ) } @{ $args{custom_exclusions} } ];
    }

    # Prepare updated job data and save to database FIRST
    # This ensures Protected Items will read the updated configuration
    # Note: run_now is NOT stored in the database - it's only used to trigger immediate execution
    my $job_data = {
        name              => $name,
        description       => $args{description} || '',
        schedule_type     => $schedule_type,
        schedule_info     => $args{schedule_info} || {},
        backup_options    => $backup_options,
        all_accounts      => $all_accounts,
        accounts          => $args{accounts}          || [],
        custom_exclusions => $args{custom_exclusions} || [],
        destinations      => $destinations,
    };

    my $dbh = $self->_get_database_handle();

    my $current_time = time();
    my $sth          = $dbh->prepare(
        q{
        UPDATE backup_jobs
        SET name = ?, description = ?, schedule_type = ?, job_config = ?, updated_timestamp = ?
        WHERE uuid = ?
    }
    );

    my $job_config_json = Cpanel::JSON::Dump($job_data);
    my $rows_affected   = $sth->execute( $name, $job_data->{description}, $schedule_type, $job_config_json, $current_time, $job_uuid );

    # Verify the job exists and was updated (or values were unchanged)
    unless ( defined $rows_affected ) {
        return 0;    # Query failed
    }

    if ( $rows_affected == 0 ) {

        # 0 rows affected could mean job doesn't exist OR values didn't change
        # Check if job exists by querying for it (regardless of is_active status)
        my $verify_sth = $dbh->prepare(q{SELECT 1 FROM backup_jobs WHERE uuid = ? LIMIT 1});
        $verify_sth->execute($job_uuid);
        my $exists = $verify_sth->fetchrow_array();
        return 0 unless $exists;
    }

    # NOW manage Protected Items with the updated configuration
    # Get the current accounts list from existing job
    my @old_accounts =
      $existing_job->{accounts} && ref $existing_job->{accounts} eq 'ARRAY'
      ? @{ $existing_job->{accounts} }
      : ();
    my @new_accounts =
      $args{accounts} && ref $args{accounts} eq 'ARRAY'
      ? @{ $args{accounts} }
      : ();

    # Create sets for comparison
    my %old_accounts_set = map { $_ => 1 } @old_accounts;
    my %new_accounts_set = map { $_ => 1 } @new_accounts;

    # Find accounts being added and removed
    my @accounts_to_add    = grep { !exists $old_accounts_set{$_} } @new_accounts;
    my @accounts_to_remove = grep { !exists $new_accounts_set{$_} } @old_accounts;

    # Handle Protected Item changes and ensure all accounts have proper Protected Items
    # This provides self-healing by checking all accounts, not just newly added ones
    require Whostmgr::CometBackup::ProtectedItemMap;
    my $map       = Whostmgr::CometBackup::ProtectedItemMap->new();
    my $items_dbh = $map->_get_database_handle();

    # Remove Protected Items for accounts being removed from the job
    if (@accounts_to_remove) {
        my $placeholders = join( ',', ('?') x @accounts_to_remove );
        my $remove_sth   = $items_dbh->prepare(
            qq{
            UPDATE protected_items
            SET status = 'disabled',
                updated_timestamp = ?
            WHERE job_uuid = ? AND system_user IN ($placeholders)
        }
        );

        my $items_removed = $remove_sth->execute( time(), $job_uuid, @accounts_to_remove );

        if ( $items_removed && $items_removed > 0 ) {
            $logger->debug(
                "Disabled Protected Items for accounts removed from backup job",
                {
                    job_uuid         => $job_uuid,
                    accounts_removed => \@accounts_to_remove,
                    items_disabled   => $items_removed
                }
            );
        }
    }

    # Determine expected item types from the NEW backup_options (after modification)
    # We manually calculate this since the job hasn't been updated in the DB yet
    my @expected_item_types;

    # 1. Files and Folders PI for homedir
    my $homedir_enabled = 0;
    if ( exists $backup_options->{homedir_files} ) {
        $homedir_enabled = $backup_options->{homedir_files} ? 1 : 0;
    }
    elsif ( exists $backup_options->{homedir} ) {
        my $homedir_val = $backup_options->{homedir};
        if ( !defined $homedir_val || $homedir_val eq '0' || $homedir_val == 0 ) {
            $homedir_enabled = 0;
        }
        else {
            $homedir_enabled = 1;
        }
    }
    if ($homedir_enabled) {
        push @expected_item_types, 'homedir_files_folders';
    }

    # 2. Program Output PI for databases
    my $databases_enabled = 0;
    if ( my $db_opt = $backup_options->{databases} ) {
        if ( ref $db_opt eq 'HASH' ) {
            $databases_enabled = ( $db_opt->{mysql} || $db_opt->{postgresql} ) ? 1 : 0;
        }
        else {
            $databases_enabled = $db_opt ? 1 : 0;
        }
    }
    if ($databases_enabled) {
        push @expected_item_types, 'databases_program_output';
    }

    # 3. Program Output PI for panel_config
    if ( $backup_options->{panel_config} ) {
        push @expected_item_types, 'pkgacct_program_output';
    }

    # Create sets for comparison
    my %expected_types_set = map { $_ => 1 } @expected_item_types;

    # Self-healing: Ensure all accounts have the correct Protected Items
    # This handles: new accounts, existing accounts with missing items, and disabling obsolete items
    if (@new_accounts) {

        # Check each account and ensure it has all expected Protected Items
        for my $account (@new_accounts) {
            eval {
                # Get existing Protected Items for this user/job combination
                my @existing_items = $map->get_protected_items_for_user(
                    system_user => $account,
                    job_uuid    => $job_uuid,
                );

                # Create a set of existing item types (only active ones)
                my %existing_types = map { $_->{item_type} => 1 }
                  grep { $_->{status} ne 'disabled' } @existing_items;

                # Find item types to add and remove
                my @types_to_add    = grep { !exists $existing_types{$_} } @expected_item_types;
                my @types_to_remove = grep { !exists $expected_types_set{$_} } keys %existing_types;

                # Disable Protected Items for types no longer in the backup configuration
                if (@types_to_remove) {
                    my $placeholders = join( ',', ('?') x @types_to_remove );
                    my $disable_sth  = $items_dbh->prepare(
                        qq{
                        UPDATE protected_items
                        SET status = 'disabled',
                            updated_timestamp = ?
                        WHERE job_uuid = ? AND system_user = ? AND item_type IN ($placeholders)
                    }
                    );

                    my $items_disabled = $disable_sth->execute( time(), $job_uuid, $account, @types_to_remove );

                    if ( $items_disabled && $items_disabled > 0 ) {
                        $logger->debug(
                            "Disabled Protected Items for removed backup types",
                            {
                                job_uuid       => $job_uuid,
                                system_user    => $account,
                                disabled_types => \@types_to_remove,
                                items_disabled => $items_disabled,
                            }
                        );
                    }
                }

                # Ensure all expected Protected Items exist and clean up duplicates
                # We process ALL expected types (not just new ones) to ensure complete duplicate cleanup
                if (@expected_item_types) {
                    $logger->debug(
                        "Ensuring Protected Items for account",
                        {
                            job_uuid        => $job_uuid,
                            system_user     => $account,
                            expected_types  => \@expected_item_types,
                            types_to_add    => \@types_to_add,
                            types_to_remove => \@types_to_remove,
                        }
                    );

                    # Create/update Protected Items for ALL expected types
                    # This ensures complete duplicate cleanup across all item types for this user/job
                    # The method will: find existing items, clean up duplicates, create missing items,
                    # and re-enable disabled items as needed
                    $map->create_protected_items_for_user_job(
                        system_user => $account,
                        job_uuid    => $job_uuid,
                        item_types  => \@expected_item_types,
                    );
                }
            };
            if ($@) {
                $logger->warn(
                    "Failed to ensure Protected Items for account in backup job",
                    {
                        job_uuid    => $job_uuid,
                        system_user => $account,
                        error       => $@
                    }
                );
            }
        }

        if (@accounts_to_add) {
            $logger->debug(
                "Created Protected Items for accounts added to backup job",
                {
                    job_uuid       => $job_uuid,
                    accounts_added => \@accounts_to_add,
                }
            );
        }

        # Sync new Protected Items to Comet server
        # This ensures that any newly created Protected Items (status='pending') get synced
        eval {
            require Whostmgr::CometBackup::CometBackupAPI::ProtectedItemBatch;

            $logger->debug(
                "Syncing Protected Items to Comet server after job modification",
                { job_uuid => $job_uuid }
            );

            my $sync_success = Whostmgr::CometBackup::CometBackupAPI::ProtectedItemBatch->sync_protected_items();

            if ($sync_success) {
                $logger->debug(
                    "Successfully synced Protected Items to Comet server",
                    { job_uuid => $job_uuid }
                );
            }
            else {
                $logger->warn(
                    "Failed to sync Protected Items to Comet server - items will remain in pending status",
                    { job_uuid => $job_uuid }
                );
            }
        };
        if ($@) {
            $logger->error(
                "Error syncing Protected Items to Comet server",
                {
                    job_uuid => $job_uuid,
                    error    => $@
                }
            );
        }
    }

    # Job modification successful
    return 1;
}

=head2 permanently_delete_backup_job

    my $success = $jobs->permanently_delete_backup_job($job_uuid);

Permanently removes a backup job from the database. This method is
equivalent to delete_backup_job and is kept for backward compatibility.

Parameters:
- job_uuid: The UUID of the backup job to permanently delete

Returns: Number of records removed

=cut

sub permanently_delete_backup_job ( $self, $job_uuid ) {
    Cpanel::Validate::UUID::validate_uuid_or_die( $job_uuid, 'job_uuid' );

    my $dbh = $self->_get_database_handle();

    my $sth = $dbh->prepare(
        q{
        DELETE FROM backup_jobs
        WHERE uuid = ?
    }
    );

    return $sth->execute($job_uuid);
}

=head2 get_next_run_time

    my $time_until_next_run = $jobs->get_next_run_time($job_uuid);

Gets the next run time for a specific backup job using the Scheduling module.

Parameters:
- job_uuid: The UUID of the backup job

Returns: String describing time until next run (e.g., "5 hours", "2 days")
         Returns undef if job not found or is manual/inactive

=cut

sub get_next_run_time ( $self, $job_uuid ) {
    Cpanel::Validate::UUID::validate_uuid_or_die( $job_uuid, 'job_uuid' );

    my @jobs = $self->get_backup_jobs( [$job_uuid] );
    return unless @jobs;

    my $job       = $jobs[0];
    my $scheduler = $self->_get_scheduler();
    return $scheduler->next_run_at($job);
}

# Private methods

=head2 _build_order_by_clause

Builds SQL ORDER BY clause based on sort field and direction.

Parameters:
- sort_by: String, field to sort by
- sort_direction: String, sort direction (asc/desc)

Returns: String containing ORDER BY clause

=cut

sub _build_order_by_clause ( $self, $sort_by, $sort_direction ) {
    $sort_direction = lc( $sort_direction || 'desc' );

    # Map user-friendly field names to database columns
    my %field_map = (
        'name'     => 'name',
        'last_run' => 'last_run_timestamp',
        'created'  => 'created_timestamp',
    );

    # Handle database-sortable fields
    if ( $sort_by && exists $field_map{$sort_by} ) {
        my $db_column = $field_map{$sort_by};

        # For last_run, treat NULL as 0 (never run) for proper sorting
        if ( $sort_by eq 'last_run' ) {
            return " ORDER BY COALESCE($db_column, 0) $sort_direction";
        }

        return " ORDER BY $db_column $sort_direction";
    }

    # Fields that need in-memory sorting (next_run, frequency, retain)
    # will be sorted in _sort_results_in_memory
    if ( $sort_by && ( $sort_by eq 'next_run' || $sort_by eq 'frequency' || $sort_by eq 'retain' ) ) {

        # Return default ordering, will be sorted in memory after
        return ' ORDER BY created_timestamp DESC';
    }

    # Default: sort by created_timestamp descending
    return ' ORDER BY created_timestamp DESC';
}

=head2 _sort_results_in_memory

Sorts job results in memory for fields that are computed dynamically.

Parameters:
- results: Array reference of job hash references
- sort_by: String, field to sort by (next_run, frequency, retain)
- sort_direction: String, sort direction (asc/desc)

Returns: Array of sorted job hash references

=cut

sub _sort_results_in_memory ( $self, $results, $sort_by, $sort_direction ) {
    return @$results unless $sort_by && @$results;

    my $direction_multiplier = ( lc($sort_direction) eq 'asc' ) ? 1 : -1;

    my @sorted;

    if ( $sort_by eq 'next_run' ) {

        # Sort by next_run timestamp
        # Manual jobs (no next_run) go to end when asc, beginning when desc
        @sorted = sort {
            my $a_next = $a->{next_run} || 0;
            my $b_next = $b->{next_run} || 0;

            # Jobs with no next_run (manual jobs) - respect sort direction
            return 1 * $direction_multiplier  if !$a_next && $b_next;
            return -1 * $direction_multiplier if $a_next  && !$b_next;
            return 0                          if !$a_next && !$b_next;

            # Normal comparison for jobs with next_run
            return ( $a_next <=> $b_next ) * $direction_multiplier;
        } @$results;
    }
    elsif ( $sort_by eq 'frequency' ) {

        # Sort by schedule frequency (daily, weekly, monthly, manual)
        # Order: manual > daily > weekly > monthly (or reversed)
        my %frequency_order = (
            'manual'  => 0,
            'daily'   => 1,
            'weekly'  => 2,
            'monthly' => 3,
        );

        @sorted = sort {
            my $a_freq = $a->{schedule_info}->{frequency} || 'manual';
            my $b_freq = $b->{schedule_info}->{frequency} || 'manual';

            my $a_order = $frequency_order{$a_freq} // 99;
            my $b_order = $frequency_order{$b_freq} // 99;

            return ( $a_order <=> $b_order ) * $direction_multiplier;
        } @$results;
    }
    elsif ( $sort_by eq 'retain' ) {

        # Sort by retention policy (if available in backup_options)
        # For now, this is a placeholder - retention might be stored differently
        # Sorting alphabetically by name as fallback
        @sorted = sort { ( $a->{name} cmp $b->{name} ) * $direction_multiplier; } @$results;
    }
    else {
        # Unknown sort field, return unsorted
        @sorted = @$results;
    }

    return @sorted;
}

sub _normalize_backup_options ( $self, $backup_options ) {

    # Normalize databases field: support both databases: 1 and databases: {mysql: 1, postgresql: 1}
    if ( exists $backup_options->{databases} ) {
        my $databases = $backup_options->{databases};

        # If databases is a simple scalar (0 or 1), convert to hash format
        if ( !ref $databases ) {

            # Convert databases: 1 to {mysql: 1, postgresql: 1}
            # Convert databases: 0 to {mysql: 0, postgresql: 0}
            my $db_value = $databases ? 1 : 0;
            $backup_options->{databases} = {
                mysql      => $db_value,
                postgresql => $db_value,
            };
        }

        # If it's already a hash, leave it as-is for validation
    }

    return;
}

sub _validate_backup_options ( $self, $backup_options ) {

    # Helper function to validate boolean values
    my $validate_boolean = sub {
        my ( $value, $field_name ) = @_;
        unless ( defined $value && ( $value == 0 || $value == 1 ) ) {
            die "$field_name must be 0 or 1";
        }
    };

    # Validate panel_config if present
    if ( exists $backup_options->{panel_config} ) {
        $validate_boolean->( $backup_options->{panel_config}, 'backup_options.panel_config' );
    }

    # Validate databases section if present
    if ( exists $backup_options->{databases} ) {
        my $databases = $backup_options->{databases};
        unless ( ref $databases eq 'HASH' ) {
            die "backup_options.databases must be a hash reference";
        }

        # Validate individual database options
        for my $db_type (qw(mysql postgresql)) {
            if ( exists $databases->{$db_type} ) {
                $validate_boolean->( $databases->{$db_type}, "backup_options.databases.$db_type" );
            }
        }
    }

    # Validate database_users if present
    if ( exists $backup_options->{database_users} ) {
        $validate_boolean->( $backup_options->{database_users}, 'backup_options.database_users' );
    }

    # Validate email_accounts section if present
    if ( exists $backup_options->{email_accounts} ) {
        my $email_accounts = $backup_options->{email_accounts};
        unless ( ref $email_accounts eq 'HASH' ) {
            die "backup_options.email_accounts must be a hash reference";
        }

        # Validate email account options
        if ( exists $email_accounts->{email_contents} ) {
            $validate_boolean->( $email_accounts->{email_contents}, 'backup_options.email_accounts.email_contents' );
        }
        if ( exists $email_accounts->{exclude_junk} ) {
            $validate_boolean->( $email_accounts->{exclude_junk}, 'backup_options.email_accounts.exclude_junk' );
        }
    }

    # Validate cron_jobs if present
    if ( exists $backup_options->{cron_jobs} ) {
        $validate_boolean->( $backup_options->{cron_jobs}, 'backup_options.cron_jobs' );
    }

    # Validate domains if present
    if ( exists $backup_options->{domains} ) {
        $validate_boolean->( $backup_options->{domains}, 'backup_options.domains' );
    }

    # Validate ssl_certs if present
    if ( exists $backup_options->{ssl_certs} ) {
        $validate_boolean->( $backup_options->{ssl_certs}, 'backup_options.ssl_certs' );
    }

    # Validate homedir section if present
    if ( exists $backup_options->{homedir} ) {
        my $homedir = $backup_options->{homedir};
        unless ( ref $homedir eq 'HASH' ) {
            die "backup_options.homedir must be a hash reference";
        }

        # Validate exclude_regex array if present
        if ( exists $homedir->{exclude_regex} ) {
            unless ( ref $homedir->{exclude_regex} eq 'ARRAY' ) {
                die "backup_options.homedir.exclude_regex must be an array reference";
            }

            # Validate each regex pattern
            for my $pattern ( @{ $homedir->{exclude_regex} } ) {
                unless ( defined $pattern ) {    # && length $pattern ) {
                    die "backup_options.homedir.exclude_regex patterns must be non-empty strings";
                }
            }
        }
    }

    return;
}

=head2 sanitize_text_input

    my $sanitized = Whostmgr::CometBackup::BackupJobs::sanitize_text_input($input, $field_name);

Sanitizes user input to prevent injection attacks and ensure safe storage.
This is a static function that can be called without an object instance.

Parameters:
- input: The text input to sanitize
- field_name: The name of the field (used in error messages)

Returns: The sanitized input string

Throws: Dies if input contains potentially malicious content or exceeds max length

=cut

sub sanitize_text_input ( $input, $field_name ) {
    return '' unless defined $input;

    # Remove null bytes (follows cPanel pattern in FilesysVirtual.pm, AppPort.pm)
    $input =~ s/\x00//g;

    # Remove ASCII control characters except tab, newline, and carriage return
    # Defense-in-depth: prevents injection of control sequences
    $input =~ s/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]//g;

    # Defense-in-depth: Reject malicious content patterns
    # Note: Data is stored in JSON (Cpanel::JSON::Dump handles escaping)
    # and displayed via React (auto-escapes), but we reject these patterns
    # as an additional security layer beyond cPanel's minimum standards
    if ( $input =~ /<script[\s>]/i || $input =~ /javascript:/i || $input =~ /on\w+\s*=/i ) {
        die "$field_name contains potentially malicious content";
    }

    # Limit length to prevent DoS attacks
    my $max_length = 1000;
    if ( length($input) > $max_length ) {
        die "$field_name exceeds maximum length of $max_length characters";
    }

    return $input;
}

# This function enhances the raw job_config data from the database with commonly used values.
# We need this to avoid duplication of data stored in the db
sub _enhance_job_data ( $self, $job_data ) {
    my %enhanced_job = %$job_data;

    # Rename uuid to job_id for API consistency
    $enhanced_job{job_id} = delete $enhanced_job{uuid};

    # Get status from database - if it's 'never_run', check for any actual runs
    my $db_status     = $enhanced_job{status} || 'never_run';
    my $job_uuid      = $enhanced_job{job_id};
    my $actual_status = '';                                     # Default to empty string for never run jobs

    # If the job has been run (status != 'never_run'), get the most recent run status
    if ( $db_status ne 'never_run' ) {
        $actual_status = $db_status;
    }

    # Also check BackupRunLogs for the most recent run to get real-time status
    our $IN_STATUS_CHECK;
    if ( !$IN_STATUS_CHECK ) {
        local $IN_STATUS_CHECK = 1;
        eval {
            require Whostmgr::CometBackup::BackupRunLogs;
            my $logs = Whostmgr::CometBackup::BackupRunLogs->new();

            # Get the most recent run for this job
            my @recent_runs = $logs->get_recent_runs(
                job_uuid => $job_uuid,
                limit    => 1,
            );
            if (@recent_runs) {
                my $latest_run = $recent_runs[0];

                # Return the actual run status: running, completed, failed, cancelled
                $actual_status = $latest_run->{run_status} || '';
            }
        };
        if ($@) {
            warn "Failed to check run status for job $job_uuid: $@";

            # Keep the status from database or empty string
        }
    }

    $enhanced_job{status} = $actual_status;

    # Use last_run_timestamp from database only if the job has actually been run
    # Do not default to created_timestamp as that would incorrectly imply the job has run
    $enhanced_job{last_run} = $enhanced_job{last_run_timestamp};
    delete $enhanced_job{last_run_timestamp};    # Remove the database field name

    # Include run_uuid if there's a current run
    if ( $enhanced_job{current_run_uuid} ) {
        $enhanced_job{run_uuid} = $enhanced_job{current_run_uuid};
    }
    delete $enhanced_job{current_run_uuid};      # Remove the database field name

    # Calculate next_run based on schedule_info using Scheduling module
    # Pass last_run to enforce 24-hour cooldown period between backups
    if ( $enhanced_job{schedule_type} eq 'scheduled' && $enhanced_job{schedule_info} ) {
        my $scheduler = $self->_get_scheduler();
        $enhanced_job{next_run} = $scheduler->calculate_next_run(
            $enhanced_job{schedule_info},
            $enhanced_job{last_run}    # Pass last_run timestamp for cooldown enforcement
        );
    }
    else {
        $enhanced_job{next_run} = undef;
    }

    # Ensure destinations is always an array and enrich with full destination data
    if ( !$enhanced_job{destinations} || ref $enhanced_job{destinations} ne 'ARRAY' ) {
        $enhanced_job{destinations} = [];
    }

    # Convert destinations from internal format to enriched destination objects
    # Destinations are now stored as UUIDs, so we need to fetch details from Comet API
    my @enriched_destinations;

    # Get destination details from Comet API profile (cached per request)
    my $comet_destinations = $self->_get_comet_destinations_cached();

    for my $dest ( @{ $enhanced_job{destinations} } ) {
        my $dest_id;

        # Handle both old format (hash with storage_type) and new format (UUID string or hash with id)
        if ( ref $dest eq 'HASH' ) {
            $dest_id = $dest->{id} || $dest->{storage_type};
        }
        else {
            # Plain string UUID
            $dest_id = $dest;
        }

        next unless $dest_id;

        # Look up destination details from Comet API
        my $comet_dest = $comet_destinations->{$dest_id};

        if ($comet_dest) {

            # Use actual destination data from Comet
            push @enriched_destinations, {
                id                  => $dest_id,
                storage_type        => $comet_dest->{storage_type},
                storage_name        => $comet_dest->{storage_name},
                storage_description => $comet_dest->{storage_description},
                size_used           => $comet_dest->{size_used}   || 0,
                size_total          => $comet_dest->{size_total}  || 0,
                recommended         => $comet_dest->{recommended} || 0,
                ( $comet_dest->{storage_path} ? ( storage_path => $comet_dest->{storage_path} ) : () ),
            };
        }
        else {
            # Fallback: if destination not found in Comet (maybe deleted), show minimal info
            push @enriched_destinations, {
                id                  => $dest_id,
                storage_type        => $dest_id,
                storage_name        => "Unknown Storage",
                storage_description => "Destination not found",
                size_used           => 0,
                size_total          => 0,
                recommended         => 0,
            };
        }
    }

    # Replace with enriched destination data
    $enhanced_job{destinations} = \@enriched_destinations if @enriched_destinations;

    # Clean up legacy fields that shouldn't be at the job level
    delete $enhanced_job{local_path}   if exists $enhanced_job{local_path};
    delete $enhanced_job{storage_type} if exists $enhanced_job{storage_type};

    return \%enhanced_job;
}

=head2 get_destination_metadata

    my $metadata = $jobs->get_destination_metadata($destination_id);
    my @all_metadata = $jobs->get_destination_metadata();

Retrieves destination metadata from the local database.
If called with a destination_id, returns a single hash reference.
If called without parameters, returns an array of all destination metadata.

Returns undef or empty array if no destinations found.

=cut

sub get_destination_metadata ( $self, $destination_id = undef ) {
    my $dbh = $self->_get_database_handle();

    if ( defined $destination_id ) {

        # Get specific destination
        my $sth = $dbh->prepare(
            q{
            SELECT destination_id, name, description, destination_type,
                   local_path, create_time, modify_time
            FROM destinations
            WHERE destination_id = ?
        }
        );
        $sth->execute($destination_id);
        my $row = $sth->fetchrow_hashref();
        return $row;
    }
    else {
        # Get all destinations
        my $sth = $dbh->prepare(
            q{
            SELECT destination_id, name, description, destination_type,
                   local_path, create_time, modify_time
            FROM destinations
            ORDER BY name ASC
        }
        );
        $sth->execute();

        my @destinations;
        while ( my $row = $sth->fetchrow_hashref() ) {
            push @destinations, $row;
        }

        return @destinations;
    }
}

=head2 update_destination_metadata

    $jobs->update_destination_metadata(
        $destination_id,
        name        => 'Updated Name',
        description => 'Updated Description',
    );

Updates destination metadata in the local database.
Only the fields provided will be updated.

=cut

sub update_destination_metadata ( $self, $destination_id, %updates ) {
    my $dbh = $self->_get_database_handle();

    # Build UPDATE statement dynamically based on provided fields
    my @fields;
    my @values;

    for my $field (qw(name description local_path)) {
        if ( exists $updates{$field} ) {
            push @fields, "$field = ?";
            push @values, $updates{$field};
        }
    }

    return 0 unless @fields;

    # Always update modify_time
    push @fields, 'modify_time = ?';
    push @values, time();

    # Add destination_id for WHERE clause
    push @values, $destination_id;

    my $sql = sprintf(
        'UPDATE destinations SET %s WHERE destination_id = ?',
        join( ', ', @fields )
    );

    my $sth           = $dbh->prepare($sql);
    my $rows_affected = $sth->execute(@values);

    return $rows_affected || 0;
}

sub _get_scheduler ($self) {
    unless ( $self->{_scheduler} ) {
        $self->{_scheduler} = Whostmgr::CometBackup::Scheduling->new();
    }
    return $self->{_scheduler};
}

=head2 _get_comet_destinations_cached

Returns a hash of destination details from Comet API, cached per instance.

Returns hashref: { destination_id => { storage_type, storage_name, size_used, size_total, ... } }

=cut

sub _get_comet_destinations_cached ($self) {

    # Return cached destinations if available
    return $self->{_comet_destinations_cache} if $self->{_comet_destinations_cache};

    # Fetch from Comet API
    my %destinations_by_id;

    eval {
        require Whostmgr::CometBackup::CometBackupAPI;

        # Get user profile from Comet server which includes all configured destinations
        my $profile_result = Whostmgr::CometBackup::CometBackupAPI::make_server_request(
            "user/web/get-user-profile-and-hash",
            {}
        );

        unless ( $profile_result && $profile_result->{Status} == 200 && $profile_result->{Profile} ) {
            die "Failed to retrieve user profile from Comet server";
        }

        my $profile = $profile_result->{Profile};
        my $dests   = $profile->{Destinations} || {};

        for my $dest_id ( keys %$dests ) {
            my $dest = $dests->{$dest_id};

            # Determine storage type and get size information
            my $dest_type    = $dest->{DestinationType} || 0;
            my $storage_type = 'unknown';
            my $storage_name = $dest->{Description} || 'Unnamed Storage';
            my $recommended  = 0;
            my $storage_path = '';
            my $size_used    = 0;
            my $size_total   = 0;

            if ( $dest_type == 1000 ) {

                # Comet Storage (cloud)
                $storage_type = 'cloud';
                $recommended  = 1;

                if ( $dest->{Statistics} && $dest->{Statistics}->{ClientProvidedSize} ) {
                    $size_used = $dest->{Statistics}->{ClientProvidedSize}->{Size} || 0;
                }

                if ( $dest->{StorageLimitEnabled} && $dest->{StorageLimitBytes} ) {
                    $size_total = $dest->{StorageLimitBytes};
                }
            }
            elsif ( $dest_type == 1002 ) {

                # Local Copy destination
                $storage_type = 'local';
                $storage_path = $dest->{LocalcopyPath} || '';

                if ($storage_path) {
                    eval {
                        local $ENV{LC_ALL} = 'C';
                        my $df_output = `df -B 1 '$storage_path' 2>/dev/null | tail -n 1`;

                        if ( $df_output && $df_output =~ /\s+(\d+)\s+(\d+)\s+(\d+)\s+\d+%/ ) {
                            $size_total = int($1);
                            $size_used  = int($2);
                        }
                    };
                }
            }
            else {
                # Other destination types (S3, B2, etc.)
                if ( $dest->{S3Server} && $dest->{S3BucketName} ) {
                    $storage_type = 's3';
                }
                elsif ( $dest->{B2}->{AccountID} ) {
                    $storage_type = 'b2';
                }
                elsif ( $dest->{FTPServer} ) {
                    $storage_type = 'ftp';
                }
                elsif ( $dest->{SFTPServer} ) {
                    $storage_type = 'sftp';
                }
                elsif ( $dest->{Swift}->{Username} ) {
                    $storage_type = 'swift';
                }
                elsif ( $dest->{WebDav}->{URL} ) {
                    $storage_type = 'webdav';
                }
                elsif ( $dest->{Storj}->{SatelliteAddress} ) {
                    $storage_type = 'storj';
                }
                elsif ( $dest->{SMB}->{SMBServer} ) {
                    $storage_type = 'smb';
                }

                if ( $dest->{Statistics} && $dest->{Statistics}->{ClientProvidedSize} ) {
                    $size_used = $dest->{Statistics}->{ClientProvidedSize}->{Size} || 0;
                }

                if ( $dest->{StorageLimitEnabled} && $dest->{StorageLimitBytes} ) {
                    $size_total = $dest->{StorageLimitBytes};
                }
            }

            $destinations_by_id{$dest_id} = {
                storage_type        => $storage_type,
                storage_name        => $storage_name,
                storage_description => $storage_name,
                size_used           => $size_used,
                size_total          => $size_total,
                recommended         => $recommended,
                ( $storage_path ? ( storage_path => $storage_path ) : () ),
            };
        }
    };

    if ($@) {
        warn "Failed to fetch destinations from Comet API: $@";

        # Return empty hash on error
        return {};
    }

    # Cache for this instance
    $self->{_comet_destinations_cache} = \%destinations_by_id;

    return \%destinations_by_id;
}

sub _get_storage_usage ( $self, $storage_path ) {

    # Get real disk usage for a given path
    my ( $size_used, $size_total ) = ( 0, 0 );

    eval {
        # Ensure that df output is consistent, regardless of locale settings
        local $ENV{LC_ALL} = 'C';

        # Get filesystem stats for the backup path using 1-byte blocks
        my $df_output = `df -B 1 '$storage_path' 2>/dev/null | tail -n 1`;

        if ( $df_output && $df_output =~ /\s+(\d+)\s+(\d+)\s+(\d+)\s+\d+%/ ) {
            $size_total = $1;
            $size_used  = $2;
        }
    };

    if ($@) {
        warn "Failed to get disk usage for $storage_path: $@";
    }

    return ( $size_used, $size_total );
}

sub _ensure_database ($self) {
    my $db_path = $Whostmgr::CometBackup::Constants::BACKUP_JOBS_DB;

    # Ensure directory exists
    my ($db_dir) = $db_path =~ m{^(.+)/[^/]+$};
    if ( $db_dir && !-d $db_dir ) {
        Cpanel::Mkdir::ensure_directory_existence_and_mode( $db_dir, 0755 );
    }

    # Create database file if it doesn't exist
    if ( !-f $db_path ) {
        Cpanel::FileUtils::TouchFile::touchfile($db_path);
    }

    # Always ensure schema is up to date (CREATE TABLE IF NOT EXISTS handles existing tables)
    my $dbh = $self->_get_database_handle();
    $self->_create_database_schema($dbh);

    return;
}

sub _get_database_handle ($self) {
    my $db_path = $Whostmgr::CometBackup::Constants::BACKUP_JOBS_DB;

    unless ( -f $db_path ) {
        die "Database file does not exist: $db_path";
    }

    my $dbh = Cpanel::DBI::SQLite->connect(
        {
            database => $db_path,
        }
    );

    # Enable foreign keys and other pragmas
    $dbh->do('PRAGMA foreign_keys = ON');
    $dbh->do('PRAGMA journal_mode = WAL');

    return $dbh;
}

sub _create_database_schema ( $self, $dbh ) {
    $dbh->do(
        q{
        CREATE TABLE IF NOT EXISTS backup_jobs (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            uuid TEXT NOT NULL UNIQUE,
            name TEXT NOT NULL,
            description TEXT,
            schedule_type TEXT NOT NULL,
            job_config TEXT NOT NULL,
            status TEXT DEFAULT 'pending',
            last_run_timestamp INTEGER,
            current_run_uuid TEXT,
            created_timestamp INTEGER NOT NULL,
            updated_timestamp INTEGER NOT NULL,
            is_active INTEGER NOT NULL DEFAULT 1
        )
    }
    );

    # Add current_run_uuid column to existing tables if it doesn't exist
    eval { $dbh->do('ALTER TABLE backup_jobs ADD COLUMN current_run_uuid TEXT'); };

    # Ignore error if column already exists

    # Remove next_run_timestamp column if it exists from older schema
    # SQLite doesn't support DROP COLUMN, so we check if it exists and ignore it
    eval {
        my $sth = $dbh->prepare("PRAGMA table_info(backup_jobs)");
        $sth->execute();
        while ( my $row = $sth->fetchrow_hashref() ) {
            if ( $row->{name} eq 'next_run_timestamp' ) {

                # Column exists but we'll ignore it - SQLite will just ignore it in queries
                # The field won't be selected in our queries, so it's effectively unused
                last;
            }
        }
    };

    # Create indexes for better performance
    $dbh->do('CREATE INDEX IF NOT EXISTS idx_backup_jobs_uuid ON backup_jobs(uuid)');
    $dbh->do('CREATE INDEX IF NOT EXISTS idx_backup_jobs_name ON backup_jobs(name)');
    $dbh->do('CREATE INDEX IF NOT EXISTS idx_backup_jobs_schedule_type ON backup_jobs(schedule_type)');
    $dbh->do('CREATE INDEX IF NOT EXISTS idx_backup_jobs_status ON backup_jobs(status)');
    $dbh->do('CREATE INDEX IF NOT EXISTS idx_backup_jobs_is_active ON backup_jobs(is_active)');
    $dbh->do('CREATE INDEX IF NOT EXISTS idx_backup_jobs_created_timestamp ON backup_jobs(created_timestamp)');
    $dbh->do('CREATE INDEX IF NOT EXISTS idx_backup_jobs_last_run_timestamp ON backup_jobs(last_run_timestamp)');

    # Create deleted_backup_jobs table with same schema for preserving deleted job history
    $dbh->do(
        q{
        CREATE TABLE IF NOT EXISTS deleted_backup_jobs (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            uuid TEXT NOT NULL UNIQUE,
            name TEXT NOT NULL,
            description TEXT,
            schedule_type TEXT NOT NULL,
            job_config TEXT NOT NULL,
            status TEXT DEFAULT 'pending',
            last_run_timestamp INTEGER,
            current_run_uuid TEXT,
            created_timestamp INTEGER NOT NULL,
            updated_timestamp INTEGER NOT NULL,
            deleted_timestamp INTEGER NOT NULL,
            is_active INTEGER NOT NULL DEFAULT 0
        )
    }
    );

    # Create indexes for deleted_backup_jobs table
    $dbh->do('CREATE INDEX IF NOT EXISTS idx_deleted_backup_jobs_uuid ON deleted_backup_jobs(uuid)');
    $dbh->do('CREATE INDEX IF NOT EXISTS idx_deleted_backup_jobs_deleted_timestamp ON deleted_backup_jobs(deleted_timestamp)');

    # Create destinations table to track destination metadata including name
    $dbh->do(
        q{
        CREATE TABLE IF NOT EXISTS destinations (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            destination_id TEXT NOT NULL UNIQUE,
            name TEXT NOT NULL,
            description TEXT,
            destination_type INTEGER NOT NULL,
            local_path TEXT,
            create_time INTEGER NOT NULL,
            modify_time INTEGER NOT NULL
        )
    }
    );

    # Create indexes for destinations table
    $dbh->do('CREATE INDEX IF NOT EXISTS idx_destinations_destination_id ON destinations(destination_id)');
    $dbh->do('CREATE INDEX IF NOT EXISTS idx_destinations_name ON destinations(name)');
    $dbh->do('CREATE INDEX IF NOT EXISTS idx_destinations_create_time ON destinations(create_time)');

    return;
}

1;

__END__

=head1 DATABASE SCHEMA

The SQLite database uses the following schema:

    CREATE TABLE backup_jobs (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        uuid TEXT NOT NULL UNIQUE,
        name TEXT NOT NULL,
        description TEXT,
        schedule_type TEXT NOT NULL,
        job_config TEXT NOT NULL,
        status TEXT DEFAULT 'pending',
        last_run_timestamp INTEGER,
        created_timestamp INTEGER NOT NULL,
        updated_timestamp INTEGER NOT NULL,
        is_active INTEGER NOT NULL DEFAULT 1
    );

Note: The next_run time is not stored in the database but calculated dynamically
using the Whostmgr::CometBackup::Scheduling module based on the job's schedule_info and
current server time.

The job_config field contains a JSON-encoded representation of the complete job
configuration including backup_options, accounts, custom_exclusions, etc.

=head1 API USAGE

The API expects POST requests with the following structure:

    POST /api/comet/get_backup_jobs
    Content-Type: application/json

    {
        "job_ids": ["uuid1", "uuid2", "uuid3"],  // Optional: specific job UUIDs
        "active_only": 1                         // Optional: filter active jobs only
    }

Response format:

    {
        "status": 1,
        "data": [
            {
                "job_id": "uuid1",
                "job_config": {...},
                "status": "running|completed|failed|cancelled",
                "last_run": timestamp,
                "next_run": "5 hours|2 days|null"
            }
        ]
    }

=head1 EXAMPLES

=head2 Basic Usage

    use Whostmgr::CometBackup::BackupJobs ();

    my $jobs = Whostmgr::CometBackup::BackupJobs->new();

    # Create a new backup job
    my $job_uuid = $jobs->create_backup_job(
        name          => 'Weekly Full Backup',
        description   => 'Weekly backup of all accounts and configurations',
        schedule_type => 'scheduled',
        schedule_info => {
            frequency => 'weekly',
            when      => [7],              # Sunday
            time      => '03:00',          # 3 AM
        },
        backup_options => {
            panel_config  => 1,
            email_accounts => {
                email_contents => 1,
                exclude_junk   => 1,
            },
        },
        all_accounts       => 1,
        accounts           => [],
        custom_exclusions  => ['/tmp/*', '/var/log/*'],
        destinations       => [
            {
                id           => 'cloud',
                storage_type => 'cloud',
            },
        ],
        run_now           => 0,
    );

    print "Created backup job with UUID: $job_uuid\n";

    # Retrieve the job
    my @jobs = $jobs->get_backup_jobs([$job_uuid]);
    if (@jobs) {
        my $job = $jobs[0];
        printf "Job Name: %s\n", $job->{name};
        printf "Schedule: %s\n", $job->{schedule_type};
        printf "Status: %s\n", $job->{status};
        printf "Created: %s\n", scalar localtime($job->{created_timestamp});
    }

    # List all active jobs
    my @active_jobs = $jobs->get_backup_jobs();
    printf "Found %d active backup jobs\n", scalar @active_jobs;

    # Update a job
    my $success = $jobs->update_backup_job($job_uuid,
        description => 'Updated description',
        run_now     => 1,
    );
    print "Job updated successfully\n" if $success;

    # Disable a job (soft delete - marks as inactive)
    $jobs->disable_backup_job($job_uuid);
    print "Job marked as inactive\n";

    # Re-enable a previously disabled job
    $jobs->enable_backup_job($job_uuid);
    print "Job re-enabled\n";

    # Duplicate a job with a new name
    my $duplicate_uuid = $jobs->duplicate_backup_job($job_uuid, 'Copy of Weekly Backup');
    print "Created duplicate job with UUID: $duplicate_uuid\n";

    # Duplicate a job with auto-generated name (adds " (Copy)" suffix)
    my $auto_duplicate_uuid = $jobs->duplicate_backup_job($job_uuid);
    print "Created auto-named duplicate job with UUID: $auto_duplicate_uuid\n";

    # Or permanently delete a job (removes from database)
    # $jobs->delete_backup_job($job_uuid);
    # print "Job permanently deleted\n";

=head2 Working with Multiple Jobs

    # Get multiple specific jobs efficiently
    my @job_uuids = ('uuid1', 'uuid2', 'uuid3');
    my @selected_jobs = $jobs->get_backup_jobs(\@job_uuids);

    # Get all jobs including inactive ones
    my @all_jobs = $jobs->get_backup_jobs([], { active_only => 0 });

    # Process results
    for my $job (@selected_jobs) {
        printf "Job %s: %s (Status: %s)\n",
            $job->{job_id},
            $job->{name},
            $job->{status};
    }

=head2 Manual Backup Job

    # Create a manual backup job that runs immediately
    my $manual_job_uuid = $jobs->create_backup_job(
        name          => 'Emergency Backup',
        description   => 'Manual backup before maintenance',
        schedule_type => 'manual',
        backup_options => {
            panel_config  => 1,
            email_accounts => {
                email_contents => 0,  # Skip email contents for speed
                exclude_junk   => 1,
            },
        },
        all_accounts  => 0,
        accounts      => ['user1', 'user2'],  # Only specific accounts
        destinations  => [
            {
                id           => 'local',
                storage_type => 'local',
                storage_path => '/backup/',  # Optional, defaults to '/backup/'
            },
        ],
        run_now      => 1,
    );
