package Whostmgr::CometBackup::ProtectedItemMap;

#                                      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::ProtectedItemMap - Management system for Comet Protected Items per user per backup job

=head1 SYNOPSIS

    use Whostmgr::CometBackup::ProtectedItemMap ();
    
    my $map = Whostmgr::CometBackup::ProtectedItemMap->new();
    
    # Create Protected Items for a user when backup job runs
    my @item_uids = $map->create_protected_items_for_user_job(
        job_uuid     => '550e8400-e29b-41d4-a716-446655440000',
        system_user  => 'tau',
        item_types   => ['homedir_files_folders', 'databases_program_output', 'pkgacct_program_output'],
    );
    
    # Get Protected Items for restoration
    my @items = $map->get_protected_items_for_user(
        system_user => 'tau',
        job_uuid    => '550e8400-e29b-41d4-a716-446655440000',
    );
    
    # Mark items as synced with Comet server
    $map->mark_items_synced_to_comet(\@item_uids);

=head1 DESCRIPTION

This module manages the mapping between cPanel backup jobs, system users, and their
corresponding Protected Items on the remote Comet Backup Management server. Each
Protected Item represents a specific backup type (homedir, databases, etc.) for
a specific user within a specific backup job configuration.

Key concepts:
- Each backup job defines what types of data to backup (homedir, databases, etc.)
- Each user selected in the backup job gets individual Protected Items created
- Each Protected Item gets a unique UUID used as its name on the Comet server
- The mapping tracks which Protected Items exist for each user/job combination

The module uses an SQLite database at C</var/cpanel/comet/usermap.db> to store
the relationships between backup jobs, users, and their Protected Items on the
remote Comet server.

=cut

#                                      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.

use cPstrict;

use Cpanel::Config::LoadCpUserFile   ();
use Cpanel::DBI::SQLite              ();
use Cpanel::Exception                ();
use Cpanel::JSON                     ();
use Cpanel::Mkdir                    ();
use Cpanel::PwCache                  ();
use Data::UUID                       ();
use DBI                              ();
use Time::HiRes                      ();
use Whostmgr::CometBackup::Constants ();

use constant DATABASE_PATH => $Whostmgr::CometBackup::Constants::PROTECTED_ITEM_MAP_DB;

# Namespace UUID for generating deterministic Protected Item UIDs
# This is a fixed UUID that acts as a namespace for all Protected Items
use constant PROTECTED_ITEM_NAMESPACE => '6ba7b810-9dad-11d1-80b4-00c04fd430c8';

=head1 METHODS

=head2 new

Creates a new ProtectedItemMap instance and initializes the database.

=cut

sub new ($class) {
    my $self = bless {
        database_file => DATABASE_PATH,
        dbh           => undef,
    }, $class;
    return $self;
}

=head2 create_protected_items_for_user_job

Creates Protected Item records for a user and backup job. Item types auto-determined from job config.

=cut

sub create_protected_items_for_user_job ( $self, %args ) {
    my $job_uuid    = $args{job_uuid}    or die "job_uuid parameter required";
    my $system_user = $args{system_user} or die "system_user parameter required";
    my $item_types  = $args{item_types};    # Optional - will be auto-determined if not provided

    # Auto-determine item types from job configuration if not provided
    if ( !$item_types ) {
        $item_types = $self->_get_item_types_from_job($job_uuid);
    }

    unless ( ref $item_types eq 'ARRAY' && @$item_types ) {
        die "No valid item types found for job $job_uuid";
    }

    # Get backup job configuration for Protected Item configuration
    my $job_config     = $self->_get_job_config($job_uuid);
    my $backup_options = $job_config->{backup_options} || {};

    # Copy custom_exclusions from job_config top level into backup_options
    # for use by _create_item_configuration. The frontend/API sends custom_exclusions
    # as a top-level parameter (not nested in backup_options), but _create_item_configuration
    # expects to find it within backup_options for building item configuration.
    if ( $job_config->{custom_exclusions} && ref $job_config->{custom_exclusions} eq 'ARRAY' ) {
        $backup_options->{custom_exclusions} = $job_config->{custom_exclusions};
    }

    my $user_uuid    = $self->_get_user_uuid($system_user);
    my $current_time = time();
    my $dbh          = $self->_get_database_handle();
    my @created_uids;

    $dbh->begin_work();

    eval {
        for my $item_type (@$item_types) {

            # Generate unique UUID for this Protected Item
            my $item_uid = $self->_generate_protected_item_uid( $job_uuid, $system_user, $item_type );

            # Check if items already exist for this job_uuid + system_user + item_type combination
            # regardless of UUID (handles migration from old random UUIDs to new deterministic ones)
            # Also handles cleaning up any existing duplicates
            my $check_sth = $dbh->prepare(
                q{
                SELECT protected_item_uid, status, comet_snapshot_id, created_timestamp
                FROM protected_items 
                WHERE job_uuid = ? AND system_user = ? AND item_type = ?
                ORDER BY 
                    CASE WHEN comet_snapshot_id IS NOT NULL AND comet_snapshot_id != '' THEN 0 ELSE 1 END,
                    created_timestamp ASC
            }
            );
            $check_sth->execute( $job_uuid, $system_user, $item_type );
            my @existing_items = @{ $check_sth->fetchall_arrayref( {} ) };

            if (@existing_items) {

                # One or more items already exist
                # Keep the first one (prioritizing items with snapshots, then oldest)
                my $item_to_keep = $existing_items[0];
                my $keep_uid     = $item_to_keep->{protected_item_uid};

                # Disable all duplicates (if any)
                if ( @existing_items > 1 ) {
                    my @duplicate_uids = map { $_->{protected_item_uid} } @existing_items[ 1 .. $#existing_items ];
                    my $placeholders   = join( ',', ('?') x @duplicate_uids );
                    my $disable_sth    = $dbh->prepare(
                        qq{
                        UPDATE protected_items
                        SET status = 'disabled',
                            updated_timestamp = ?
                        WHERE protected_item_uid IN ($placeholders)
                    }
                    );
                    $disable_sth->execute( $current_time, @duplicate_uids );
                }

                # Update the item we're keeping
                my $item_config = $self->_create_item_configuration( $item_type, $backup_options );
                my $config_json = Cpanel::JSON::Dump($item_config);

                my $update_sth = $dbh->prepare(
                    q{
                    UPDATE protected_items
                    SET item_configuration = ?, 
                        status = CASE WHEN status = 'disabled' THEN 'pending' ELSE status END,
                        updated_timestamp = ?
                    WHERE protected_item_uid = ?
                }
                );
                $update_sth->execute( $config_json, $current_time, $keep_uid );

                # Use the UID of the item we kept
                push @created_uids, $keep_uid;
            }
            else {
                # No existing item - create a new one
                my $item_config = $self->_create_item_configuration( $item_type, $backup_options );
                my $config_json = Cpanel::JSON::Dump($item_config);

                my $insert_sth = $dbh->prepare(
                    q{
                    INSERT INTO protected_items 
                    (protected_item_uid, job_uuid, system_user, cpanel_user_uuid, 
                     item_type, item_configuration, status, created_timestamp, updated_timestamp, user_terminated)
                    VALUES (?, ?, ?, ?, ?, ?, 'pending', ?, ?, 0)
                }
                );

                my $rows_affected = $insert_sth->execute(
                    $item_uid,  $job_uuid,    $system_user,  $user_uuid,
                    $item_type, $config_json, $current_time, $current_time
                );

                # Only add to results if we actually inserted
                if ( $rows_affected > 0 ) {
                    push @created_uids, $item_uid;
                }
            }
        }

        $dbh->commit();
    };

    if ( my $error = $@ ) {
        $dbh->rollback();
        die "Failed to create protected items: $error";
    }

    return @created_uids;
}

=head2 get_protected_items_for_user

Gets Protected Items for a user, optionally filtered by job, type, or status.

=cut

sub get_protected_items_for_user ( $self, %args ) {
    my $system_user = $args{system_user} or die "system_user parameter required";

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

    my $sql = q{
        SELECT protected_item_uid, job_uuid, system_user, cpanel_user_uuid,
               item_type, item_configuration, status, comet_synced_timestamp,
               created_timestamp, updated_timestamp, last_backup_timestamp, user_terminated
        FROM protected_items 
        WHERE system_user = ?
    };

    my @bind_params = ($system_user);

    if ( $args{job_uuid} ) {
        $sql .= ' AND job_uuid = ?';
        push @bind_params, $args{job_uuid};
    }

    if ( $args{item_type} ) {
        $sql .= ' AND item_type = ?';
        push @bind_params, $args{item_type};
    }

    if ( $args{status} ) {
        my $status_filter = $args{status};
        if ( ref $status_filter eq 'ARRAY' ) {

            # Multiple status values - use IN clause
            my $placeholders = join( ',', ('?') x @$status_filter );
            $sql .= " AND status IN ($placeholders)";
            push @bind_params, @$status_filter;
        }
        else {
            # Single status value - use equality
            $sql .= ' AND status = ?';
            push @bind_params, $status_filter;
        }
    }

    $sql .= ' ORDER BY created_timestamp DESC';

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

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

        # Decode JSON configuration if present
        if ( $row->{item_configuration} ) {
            eval { $row->{item_configuration} = Cpanel::JSON::Load( $row->{item_configuration} ); };
            if ($@) {
                warn "Failed to decode item configuration for $row->{protected_item_uid}: $@";
                $row->{item_configuration} = {};
            }
        }

        push @items, $row;
    }

    return @items;
}

=head2 get_protected_items_for_job

Gets all Protected Items for a backup job across all users.

=cut

sub get_protected_items_for_job ( $self, %args ) {
    my $job_uuid = $args{job_uuid} or die "job_uuid parameter required";

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

    my $sql = q{
        SELECT protected_item_uid, job_uuid, system_user, cpanel_user_uuid,
               item_type, item_configuration, status, comet_synced_timestamp,
               created_timestamp, updated_timestamp, last_backup_timestamp
        FROM protected_items 
        WHERE job_uuid = ?
    };

    my @bind_params = ($job_uuid);

    if ( $args{status} ) {
        my $status_filter = $args{status};
        if ( ref $status_filter eq 'ARRAY' ) {

            # Multiple status values - use IN clause
            my $placeholders = join( ',', ('?') x @$status_filter );
            $sql .= " AND status IN ($placeholders)";
            push @bind_params, @$status_filter;
        }
        else {
            # Single status value - use equality
            $sql .= ' AND status = ?';
            push @bind_params, $status_filter;
        }
    }

    $sql .= ' ORDER BY system_user, item_type';

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

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

        # Decode JSON configuration if present
        if ( $row->{item_configuration} ) {
            eval { $row->{item_configuration} = Cpanel::JSON::Load( $row->{item_configuration} ); };
            if ($@) {
                warn "Failed to decode item configuration for $row->{protected_item_uid}: $@";
                $row->{item_configuration} = {};
            }
        }

        push @items, $row;
    }

    return @items;
}

=head2 update_protected_item_status

Updates Protected Item status and optional configuration.

=cut

sub update_protected_item_status ( $self, %args ) {
    my $protected_item_uid = $args{protected_item_uid} or die "protected_item_uid parameter required";
    my $status             = $args{status}             or die "status parameter required";

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

    my $sql = q{
        UPDATE protected_items 
        SET status = ?, updated_timestamp = ?
    };

    my @bind_params = ( $status, $current_time );

    if ( $args{configuration} ) {
        $sql .= ', item_configuration = ?';
        push @bind_params, Cpanel::JSON::canonical_dump( $args{configuration} );
    }

    $sql .= ' WHERE protected_item_uid = ?';
    push @bind_params, $protected_item_uid;

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

    return $rows_updated;
}

=head2 mark_items_synced_to_comet

Marks Protected Items as synced to Comet server.

=cut

sub mark_items_synced_to_comet ( $self, $item_uids ) {
    die "item_uids parameter required"         unless $item_uids;
    die "item_uids must be an array reference" unless ref $item_uids eq 'ARRAY';

    return 0 unless @$item_uids;

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

    my $placeholders = join( ',', ('?') x @$item_uids );
    my $sql          = qq{
        UPDATE protected_items 
        SET comet_synced_timestamp = ?, status = 'active', updated_timestamp = ?
        WHERE protected_item_uid IN ($placeholders)
    };

    my $sth = $dbh->prepare($sql);
    return $sth->execute( $current_time, $current_time, @$item_uids );
}

=head2 record_backup_completion

Records backup completion for a Protected Item.

=cut

sub record_backup_completion ( $self, %args ) {
    my $protected_item_uid = $args{protected_item_uid} or die "protected_item_uid parameter required";

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

    my $sql = q{
        UPDATE protected_items 
        SET last_backup_timestamp = ?, updated_timestamp = ?
    };

    my @bind_params = ( $current_time, $current_time );

    if ( $args{comet_snapshot_id} ) {
        $sql .= ', comet_snapshot_id = ?';
        push @bind_params, $args{comet_snapshot_id};
    }

    $sql .= ' WHERE protected_item_uid = ?';
    push @bind_params, $protected_item_uid;

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

=head2 remove_protected_items_for_job

Removes all Protected Items for a backup job.

=cut

sub remove_protected_items_for_job ( $self, $job_uuid ) {
    die "job_uuid parameter required" unless defined $job_uuid;

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

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

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

=head2 remove_protected_items_for_user

Removes all Protected Items for a user.

=cut

sub remove_protected_items_for_user ( $self, $system_user ) {
    die "system_user parameter required" unless defined $system_user;

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

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

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

=head2 get_item_summary_by_job

Gets Protected Items summary grouped by job.

=cut

sub get_item_summary_by_job ($self) {
    my $dbh = $self->_get_database_handle();

    my $sth = $dbh->prepare(
        q{
        SELECT job_uuid, status, COUNT(*) as item_count
        FROM protected_items 
        GROUP BY job_uuid, status
        ORDER BY job_uuid
    }
    );

    $sth->execute();

    my %summary;
    while ( my ( $job_uuid, $status, $count ) = $sth->fetchrow_array() ) {
        $summary{$job_uuid}{$status} = $count;
        $summary{$job_uuid}{total} = ( $summary{$job_uuid}{total} || 0 ) + $count;
    }

    return \%summary;
}

sub _get_database_handle ($self) {
    return $self->{dbh} if $self->{dbh};

    my $db_file = $self->{database_file};
    my $dbh     = DBI->connect(
        "dbi:SQLite:dbname=$db_file",
        "", "",
        {
            RaiseError                       => 1,
            PrintError                       => 0,
            AutoCommit                       => 1,
            sqlite_use_immediate_transaction => 1,
        }
    );

    # Enable foreign key constraints
    $dbh->do('PRAGMA foreign_keys = ON');

    # Initialize database if needed
    $self->_init_database_schema($dbh);

    return $self->{dbh} = $dbh;
}

sub _init_database_schema ( $self, $dbh ) {
    my $create_table_sql = q{
        CREATE TABLE IF NOT EXISTS protected_items (
            protected_item_uid TEXT PRIMARY KEY,
            job_uuid TEXT NOT NULL,
            system_user TEXT NOT NULL,
            cpanel_user_uuid TEXT NOT NULL,
            item_type TEXT NOT NULL,
            item_configuration TEXT,
            status TEXT NOT NULL DEFAULT 'pending',
            comet_synced_timestamp INTEGER,
            comet_snapshot_id TEXT,
            created_timestamp INTEGER NOT NULL,
            updated_timestamp INTEGER NOT NULL,
            user_terminated INTEGER NOT NULL DEFAULT 0,
            last_backup_timestamp INTEGER,
            terminated_timestamp INTEGER,
            terminated_estimated_usage_bytes INTEGER,
            pruned_flag INTEGER NOT NULL DEFAULT 0
        )
    };

    $dbh->do($create_table_sql);

    # Create indexes separately for better compatibility
    my @index_sqls = (
        'CREATE INDEX IF NOT EXISTS idx_pi_job_uuid ON protected_items (job_uuid)',
        'CREATE INDEX IF NOT EXISTS idx_pi_system_user ON protected_items (system_user)',
        'CREATE INDEX IF NOT EXISTS idx_pi_status ON protected_items (status)',
        'CREATE INDEX IF NOT EXISTS idx_pi_user_job ON protected_items (system_user, job_uuid)',
        'CREATE INDEX IF NOT EXISTS idx_pi_backup_time ON protected_items (last_backup_timestamp)',
        'CREATE INDEX IF NOT EXISTS idx_pi_user_terminated ON protected_items (user_terminated)',
    );

    for my $index_sql (@index_sqls) {
        $dbh->do($index_sql);
    }

    $self->_ensure_terminated_user_columns($dbh);
}

sub _ensure_terminated_user_columns ( $self, $dbh ) {
    my %existing;
    my $columns_sth = $dbh->prepare('PRAGMA table_info(protected_items)');
    $columns_sth->execute();

    while ( my $row = $columns_sth->fetchrow_hashref() ) {
        $existing{ lc $row->{name} } = 1;
    }

    my @columns = (
        [ 'user_terminated',                  'INTEGER NOT NULL DEFAULT 0' ],
        [ 'terminated_timestamp',             'INTEGER' ],
        [ 'terminated_estimated_usage_bytes', 'INTEGER' ],
        [ 'pruned_flag',                      'INTEGER NOT NULL DEFAULT 0' ],
    );

    for my $column (@columns) {
        my ( $name, $definition ) = @$column;
        next if $existing{$name};
        my $sql = "ALTER TABLE protected_items ADD COLUMN $name $definition";
        $dbh->do($sql);
    }

    return;
}

sub _get_user_uuid ( $self, $system_user ) {
    die "system_user parameter required" unless defined $system_user;

    # Try to load the user's UUID from their cpuser file
    # Use load() instead of CpUserGuard->new() to avoid file locking overhead
    # since we're only reading and cpuser files are replaced atomically (never modified in-place)
    my $cpuser_data;
    my $uuid;

    eval {
        $cpuser_data = Cpanel::Config::LoadCpUserFile::load( $system_user, { quiet => 1 } );
        $uuid        = $cpuser_data->{'UUID'} if $cpuser_data;
    };

    # If we can't get a UUID from the cpuser file (user doesn't exist or no UUID),
    # generate a deterministic UUID based on the username for consistency
    if ( !$uuid ) {

        # Use Data::UUID to generate a consistent UUID based on username
        require Digest::SHA;
        my $sha = Digest::SHA->new(256);
        $sha->add("comet_backup_user_$system_user");
        my $hash = $sha->hexdigest();

        # Format as UUID (version 4)
        $uuid = sprintf(
            "%s-%s-%s-%s-%s",
            substr( $hash, 0, 8 ),
            substr( $hash, 8, 4 ),
            "4" . substr( $hash, 12, 3 ),                                                               # Version 4 UUID
            sprintf( "%x", ( hex( substr( $hash, 16, 1 ) ) & 0x3 ) | 0x8 ) . substr( $hash, 17, 3 ),    # Variant bits
            substr( $hash, 20, 12 )
        );
    }

    return $uuid;
}

sub _generate_protected_item_uid ( $self, $job_uuid, $system_user, $item_type ) {
    die "job_uuid parameter required"    unless defined $job_uuid;
    die "system_user parameter required" unless defined $system_user;
    die "item_type parameter required"   unless defined $item_type;

    # Generate a deterministic UUID based on job_uuid, system_user and item_type
    # This ensures the same job+user+type combination always gets the same UUID,
    # preventing duplicates while allowing the same user+type across different jobs
    my $uuid_gen = Data::UUID->new();

    # Create a deterministic string that uniquely identifies this protected item
    my $unique_string = "$job_uuid|$system_user|$item_type";

    # Use create_from_name_str with a fixed namespace to generate a deterministic UUID
    my $uuid = $uuid_gen->create_from_name_str( PROTECTED_ITEM_NAMESPACE, $unique_string );

    return $uuid;
}

sub _get_item_types_from_job ( $self, $job_uuid ) {
    die "job_uuid parameter required" unless defined $job_uuid;

    # Connect to backup jobs database
    my $jobs_db_file = $Whostmgr::CometBackup::Constants::BACKUP_JOBS_DB;
    unless ( -f $jobs_db_file ) {
        die "Backup jobs database not found at $jobs_db_file";
    }

    my $jobs_dbh = DBI->connect(
        "dbi:SQLite:dbname=$jobs_db_file",
        "", "",
        {
            RaiseError => 1,
            PrintError => 0,
        }
    );

    # Get the job configuration
    my $sth = $jobs_dbh->prepare(
        q{
        SELECT job_config 
        FROM backup_jobs 
        WHERE uuid = ? AND is_active = 1
    }
    );

    $sth->execute($job_uuid);
    my ($job_config_json) = $sth->fetchrow_array();

    unless ($job_config_json) {
        die "Backup job $job_uuid not found or is not active";
    }

    # Parse the job configuration
    my $job_config;
    eval { $job_config = Cpanel::JSON::Load($job_config_json); };
    if ($@) {
        die "Failed to parse job configuration for $job_uuid: $@";
    }

    my $backup_options = $job_config->{backup_options} || {};
    my @item_types;

    # Create maximum 3 Protected Items per user:
    # 1. homedir_files_folders - Files and Folders PI for home directory
    # 2. databases_program_output - Program Output PI for databases
    # 3. pkgacct_program_output - Program Output PI for panel config (pkgacct with --skiphomedir --skipacctdb)

    # 1. Files and Folders PI for homedir (includes email content filtering)
    # Check for homedir_files first (new explicit flag), fall back to homedir being truthy and not 0
    my $homedir_enabled = 0;
    if ( exists $backup_options->{homedir_files} ) {

        # New explicit flag from UI
        $homedir_enabled = $backup_options->{homedir_files} ? 1 : 0;
    }
    elsif ( exists $backup_options->{homedir} ) {

        # Legacy: check if homedir is explicitly set to 0 (disabled), or truthy value, or hashref (enabled with options)
        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 @item_types, 'homedir_files_folders';
    }

    # 2. Program Output PI for databases only
    # databases can be: 0 (disabled), 1 (enabled - legacy), or {"mysql":1,"postgresql":0} (new format)
    my $databases_enabled = 0;
    if ( my $db_opt = $backup_options->{databases} ) {
        if ( ref $db_opt eq 'HASH' ) {

            # New format: check if either mysql or postgresql is enabled
            $databases_enabled = ( $db_opt->{mysql} || $db_opt->{postgresql} ) ? 1 : 0;
        }
        else {
            # Legacy boolean or truthy value
            $databases_enabled = $db_opt ? 1 : 0;
        }
    }

    if ($databases_enabled) {
        push @item_types, 'databases_program_output';
    }

    # 3. Program Output PI for panel_config (everything except databases and homedir)
    # This creates a single panel_config item that uses pkgacct with --skiphomedir and --skipacctdb
    if ( $backup_options->{panel_config} ) {
        push @item_types, 'pkgacct_program_output';
    }

    return \@item_types;
}

sub _get_job_config ( $self, $job_uuid ) {

    # Connect to backup jobs database
    my $jobs_db_file = $Whostmgr::CometBackup::Constants::BACKUP_JOBS_DB;
    unless ( -f $jobs_db_file ) {
        die "Backup jobs database not found at $jobs_db_file";
    }

    my $jobs_dbh = DBI->connect(
        "dbi:SQLite:dbname=$jobs_db_file",
        "", "",
        {
            RaiseError => 1,
            PrintError => 0,
        }
    );

    # Get the job configuration
    my $sth = $jobs_dbh->prepare(
        q{
        SELECT job_config 
        FROM backup_jobs 
        WHERE uuid = ? AND is_active = 1
    }
    );

    $sth->execute($job_uuid);
    my ($job_config_json) = $sth->fetchrow_array();

    unless ($job_config_json) {
        die "Backup job $job_uuid not found or is not active";
    }

    # Parse the job configuration
    my $job_config;
    eval { $job_config = Cpanel::JSON::Load($job_config_json); };
    if ($@) {
        die "Failed to parse job configuration for $job_uuid: $@";
    }

    return $job_config;
}

sub _create_item_configuration ( $self, $item_type, $backup_options ) {
    my $config = {};

    if ( $item_type eq 'homedir_files_folders' ) {

        # Files and folders configuration
        # Extract from email_accounts subsection (legacy) or homedir subsection (current)
        my $email_config   = $backup_options->{email_accounts} || {};
        my $homedir_config = $backup_options->{homedir}        || {};

        # Check both legacy (email_accounts) and current (homedir) locations for email settings
        $config->{exclude_junk}   = $homedir_config->{exclude_junk}   || $email_config->{exclude_junk}   || 0;
        $config->{email_contents} = $homedir_config->{email_contents} || $email_config->{email_contents} || 0;

        # Extract exclude patterns - check multiple locations for ease of conversion:
        # 1. homedir.exclude_regex (if already in regex format)
        # 2. custom_exclusions at backup_options level (glob patterns from frontend)
        my $exclude_patterns;
        if ( !$exclude_patterns || ( ref $exclude_patterns eq 'ARRAY' && !@$exclude_patterns ) ) {
            $exclude_patterns = $backup_options->{custom_exclusions} || [];
        }

        # Convert glob patterns to regex for Comet API
        # The frontend sends glob-style patterns (e.g., "*.txt", "cache/*")
        # but Comet expects regex patterns (e.g., ".*\.txt", "cache/.*")
        # If any regex are already defined and labeled as regex, use them as such
        my @regex_patterns = @{ $homedir_config->{exclude_regex} || [] };
        for my $pattern ( @{ $exclude_patterns || [] } ) {
            next unless defined $pattern && length $pattern;
            my $regex = $self->_glob_to_regex($pattern);
            push @regex_patterns, $regex;
        }
        $config->{exclude_regex} = \@regex_patterns;
    }
    elsif ( $item_type eq 'databases_program_output' ) {

        # Database configuration
        # The databases field is always normalized to hash format by BackupJobs.pm
        my $db_config = $backup_options->{databases} || {};
        unless ( ref $db_config eq 'HASH' ) {
            die "databases configuration must be a hash reference (normalization failed)";
        }
        $config->{mysql}      = $db_config->{mysql}      || 0;
        $config->{postgresql} = $db_config->{postgresql} || 0;
    }
    elsif ( $item_type eq 'pkgacct_program_output' ) {

        # Panel config: we no longer persist pkgacct flags.
        # All pkgacct_program_output items are treated identically and
        # the standard pkgacct flags (--skiphomedir --skipacctdb) are injected
        # at execution/sync time rather than stored per-item.
        # Intentionally leave configuration minimal to avoid redundant state.
    }

    return $config;
}

=head2 _glob_to_regex

Converts a glob-style pattern to a regex pattern for Comet API.

The frontend uses user-friendly glob patterns like:
  - "*.txt" (matches any .txt file)
  - "cache/*" (matches anything in cache directory)
  - "**/cache/*" (matches cache dir at any depth)
  - "exclude-this" (matches exact directory/file name)

Comet expects regex patterns like:
  - ".*\.txt$"
  - "cache/.*"
  - "(?:^|.*/)cache/.*"
  - "(^|.*/)exclude-this(/.*)?$"

Parameters:
- pattern: The glob pattern to convert

Returns: Regex pattern string

=cut

sub _glob_to_regex ( $self, $pattern ) {
    return '' unless defined $pattern && length $pattern;

    # If pattern already looks like a regex (contains regex metacharacters
    # that aren't typical in globs), return as-is
    # Common regex indicators: starts with ^, ends with $, contains (?:, \d, \w, etc.
    if ( $pattern =~ /^\^/ || $pattern =~ /\$$/ || $pattern =~ /\(\?[:=!]/ || $pattern =~ /\\[dwsDWS]/ ) {
        return $pattern;
    }

    # Handle simple extension patterns like *.txt, *.test
    # These should become .*\.ext$
    if ( $pattern =~ /^\*\.([^*?]+)$/ ) {
        my $ext = quotemeta($1);
        return ".*\\.$ext\$";
    }

    # Handle directory wildcard patterns like cache/*, dir/*
    # These should become dir/.*
    if ( $pattern =~ /^([^*?]+)\/\*$/ ) {
        my $dir = quotemeta($1);
        return "$dir/.*";
    }

    # Handle recursive patterns like **/cache/*, **/dir/*
    # ** can match zero or more path components, so **/cache/* should match:
    #   - cache/file.txt (cache at root)
    #   - foo/cache/file.txt (cache nested)
    # Regex: (?:^|.*/)cache/.*
    if ( $pattern =~ /^\*\*\/([^*?]+)\/\*$/ ) {
        my $dir = quotemeta($1);
        return "(?:^|.*/)$dir/.*";
    }

    # Handle recursive patterns like **/cache, **/dir (no trailing wildcard)
    # ** can match zero or more path components, so **/cache should match:
    #   - cache (at root)
    #   - foo/cache (nested)
    # But NOT: mycache, cache_dir (partial matches)
    # Regex: (?:^|.*/)cache(?:/.*)?$
    if ( $pattern =~ /^\*\*\/([^*?]+)$/ ) {
        my $rest = quotemeta($1);
        return '(?:^|.*/)' . $rest . '(?:/.*)?$';
    }

    # Escape regex metacharacters except glob wildcards (* and ?)
    my $regex = quotemeta($pattern);

    # Convert glob wildcards to regex:
    # ** matches anything including path separators (greedy across directories)
    # * matches anything except path separators (within one directory level)
    # ? matches any single character

    # Handle **/ - matches zero or more path components
    # e.g., mail/**/new matches mail/new, mail/foo/new, mail/foo/bar/new
    # Simply use .* since it matches any characters including /
    $regex =~ s{\\\*\\\*\\/}{.*}g;

    # Handle remaining ** (at end of pattern or followed by something other than /)
    $regex =~ s/\\\*\\\*/.*/g;

    # Handle remaining * (matches anything except /)
    $regex =~ s{\\\*}{[^/]*}g;

    # Handle ? (matches single character)
    $regex =~ s/\\\?/./g;

    # For absolute paths, just anchor at end
    if ( $pattern =~ m{^/} ) {
        return $regex . '$';
    }

    # For patterns without wildcards (plain names), match as path segment anywhere
    if ( $pattern !~ /[*?]/ ) {
        return "(^|.*/)$regex(/.*)?\$";
    }

    # For other patterns with wildcards, anchor at end
    return $regex . '$';
}

sub get_protected_item_mapping ( $self, $protected_item_uid ) {
    die "protected_item_uid parameter required" unless defined $protected_item_uid;

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

    my $sth = $dbh->prepare(
        q{
         SELECT system_user,
             cpanel_user_uuid AS user_uuid,
             protected_item_uid,
             user_terminated,
             terminated_timestamp,
             terminated_estimated_usage_bytes,
             pruned_flag
        FROM protected_items
        WHERE protected_item_uid = ?
    }
    );

    $sth->execute($protected_item_uid);
    return $sth->fetchrow_hashref();
}

sub remove_protected_item ( $self, $protected_item_uid ) {
    die "protected_item_uid parameter required" unless defined $protected_item_uid;

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

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

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

sub get_protected_item_config ( $self, $protected_item_uid ) {
    die "protected_item_uid parameter required" unless defined $protected_item_uid;

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

    my $sth = $dbh->prepare(
        q{
        SELECT item_type, item_name, configuration, created_timestamp, updated_timestamp
        FROM protected_item_configs
        WHERE protected_item_uid = ?
    }
    );

    $sth->execute($protected_item_uid);

    my $row = $sth->fetchrow_hashref();
    return unless $row;

    # Decode the JSON configuration
    eval { $row->{configuration} = Cpanel::JSON::Load( $row->{configuration} ); };

    if ( my $error = $@ ) {
        die "Failed to decode protected item configuration: $error";
    }

    return $row;
}

sub get_protected_item_details ( $self, $protected_item_uid ) {
    die "protected_item_uid parameter required" unless defined $protected_item_uid;

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

    my $sth = $dbh->prepare(
        q{
        SELECT protected_item_uid, job_uuid, system_user, cpanel_user_uuid,
               item_type, item_configuration, status, comet_synced_timestamp,
               created_timestamp, updated_timestamp, last_backup_timestamp
        FROM protected_items
        WHERE protected_item_uid = ?
    }
    );

    $sth->execute($protected_item_uid);

    my $row = $sth->fetchrow_hashref();
    return unless $row;

    # Decode JSON configuration if present
    if ( $row->{item_configuration} ) {
        eval { $row->{configuration} = Cpanel::JSON::Load( $row->{item_configuration} ); };
        if ($@) {
            warn "Failed to decode item configuration for $protected_item_uid: $@";
            $row->{configuration} = {};
        }
    }
    else {
        $row->{configuration} = {};
    }

    return $row;
}

sub prune_inactive_users ( $self, %args ) {
    my $older_than_days = $args{older_than_days} // die "older_than_days parameter required";
    my $dry_run         = $args{dry_run}         // 0;
    my $users           = $args{users};    # Optional arrayref of usernames to filter by

    die "older_than_days must be positive" unless $older_than_days > 0;

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

    my $cutoff_time = time() - ( $older_than_days * 24 * 60 * 60 );

    # Build SQL with optional user filtering
    my $where_clause = q{
        WHERE user_terminated = 1 
        AND terminated_timestamp IS NOT NULL 
        AND terminated_timestamp < ?
    };

    my @bind_params = ($cutoff_time);

    if ( $users && ref($users) eq 'ARRAY' && @$users ) {
        my $placeholders = join( ',', ('?') x @$users );
        $where_clause .= " AND system_user IN ($placeholders)";
        push @bind_params, @$users;
    }

    if ($dry_run) {

        # For dry-run, just count what would be deleted
        my $sql = "SELECT COUNT(*) FROM protected_items $where_clause";
        my $sth = $dbh->prepare($sql);
        $sth->execute(@bind_params);
        my ($count) = $sth->fetchrow_array();
        return $count // 0;
    }
    else {
        my $sql = "DELETE FROM protected_items $where_clause";
        my $sth = $dbh->prepare($sql);
        return $sth->execute(@bind_params);
    }
}

sub mark_user_deleted ( $self, %args ) {
    my $system_user = $args{system_user} or die "system_user parameter required";

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

    my $sth = $dbh->prepare(
        q{
        UPDATE protected_items
        SET user_terminated = 1,
            updated_timestamp = ?,
            terminated_timestamp = COALESCE(terminated_timestamp, ?),
            terminated_estimated_usage_bytes = COALESCE(?, terminated_estimated_usage_bytes),
            pruned_flag = 0
        WHERE system_user = ?
    }
    );

    my $rows = $sth->execute(
        $now,
        $now,
        $args{estimated_usage_bytes},
        $system_user,
    );

    return $rows;
}

sub mark_user_reactivated ( $self, $system_user ) {
    die "system_user parameter required" unless defined $system_user;

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

    my $sth = $dbh->prepare(
        q{
        UPDATE protected_items
        SET user_terminated = 0,
            updated_timestamp = ?,
            terminated_timestamp = NULL,
            terminated_estimated_usage_bytes = NULL,
            pruned_flag = 0
        WHERE system_user = ?
    }
    );

    return $sth->execute( $now, $system_user );
}

sub get_deleted_users ($self) {
    my $dbh = $self->_get_database_handle();

    my $sth = $dbh->prepare(
        q{
        SELECT system_user,
               MIN(terminated_timestamp) AS terminated_ts,
               MAX(terminated_estimated_usage_bytes) AS estimated_usage_bytes,
               MAX(pruned_flag) AS pruned_flag
        FROM protected_items
        WHERE user_terminated = 1
        GROUP BY system_user
        HAVING MAX(terminated_timestamp) IS NOT NULL
        ORDER BY terminated_ts DESC, system_user
    }
    );

    $sth->execute();

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

    return @users;
}

sub get_deleted_user ( $self, $system_user ) {
    die "system_user parameter required" unless defined $system_user;

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

    my $sth = $dbh->prepare(
        q{
        SELECT system_user,
               MIN(terminated_timestamp) AS terminated_ts,
               MAX(terminated_estimated_usage_bytes) AS estimated_usage_bytes,
               MAX(pruned_flag) AS pruned_flag
        FROM protected_items
        WHERE system_user = ? AND user_terminated = 1
        GROUP BY system_user
        HAVING MAX(terminated_timestamp) IS NOT NULL
    }
    );

    $sth->execute($system_user);

    return $sth->fetchrow_hashref();
}

sub set_deleted_user_pruned_flag ( $self, %args ) {
    my $system_user = $args{system_user} or die "system_user parameter required";
    my $flag        = defined $args{flag} ? $args{flag} : 1;

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

    my $sth = $dbh->prepare(
        q{
        UPDATE protected_items
        SET pruned_flag = ?,
            updated_timestamp = ?
        WHERE system_user = ? AND user_terminated = 1
    }
    );

    return $sth->execute( $flag, $now, $system_user );
}

sub purge_deleted_users_before_epoch ( $self, $cutoff_epoch ) {
    die "cutoff_epoch parameter required" unless defined $cutoff_epoch;

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

    my $sth = $dbh->prepare(
        q{
        DELETE FROM protected_items
        WHERE user_terminated = 1
          AND pruned_flag = 1
          AND terminated_timestamp IS NOT NULL
          AND terminated_timestamp < ?
    }
    );

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

sub get_all_users_with_protected_items ( $self, %options ) {
    my $dbh = $self->_get_database_handle();

    my $sql = q{
        SELECT DISTINCT system_user 
        FROM protected_items
    };

    if ( $options{active_only} ) {
        $sql .= ' WHERE user_terminated = 0';
    }

    $sql .= ' ORDER BY system_user';

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

    my @users;
    while ( my ($user) = $sth->fetchrow_array() ) {
        push @users, $user;
    }

    return @users;
}

sub get_system_user_for_protected_item ( $self, $protected_item_uid ) {
    return unless defined $protected_item_uid;

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

    my $sql = q{
        SELECT system_user 
        FROM protected_items
        WHERE protected_item_uid = ?
        LIMIT 1
    };

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

    my ($system_user) = $sth->fetchrow_array();

    return $system_user;
}

1;
