#                                     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

# BEGIN Augment generated code block
package Cpanel::Plugins::Components::Rules::Random;

use cPstrict;
use Cpanel::Hostname ();
use Digest::SHA      ();
use Cpanel::Imports;

use Moo;

extends 'Cpanel::Plugins::Components::Rules::Filter';

use constant DEFAULT_FLEET_SIZE => 100000;

=head1 MODULE

C<Cpanel::Plugins::Components::Rules::Random>

=head1 DESCRIPTION

A filter rule that randomly selects a subset of servers from those that match
a nested rule. This rule is designed to work across a distributed fleet where
each server evaluates the rule independently without knowledge of other servers.

The random selection uses a cryptographically secure random number generator
combined with server-specific entropy to ensure true randomness across the fleet.
Results are cached to disk to maintain consistent decisions across multiple
evaluations for one specific server.

Internal Details: https://webpros.atlassian.net/wiki/spaces/RD/pages/5198873202/Random+Internal+Details
Rules Design Doc: https://webpros.atlassian.net/wiki/spaces/RD/pages/5199101994/Rules+System

=head1 SYNOPSIS

Using YAML configuration: [As part of a larger YAML rule]
This one shows for the following
  * all servers on company id 7user
  * sample of 1000 servers out of 50000 servers on company id 256 or 2865

  or:
  - company-id: 7
  - random:
    rule:
      company-id: [256, 2865]
    sample_size: 1000
    fleet_size: 50000
    cache_file: "/tmp/campaign.07.16.2025.cache"

For second round sampling (preserving previous selections):
  - random:
    rule:
      company-id: [256, 2865]
    sample_size: 10000
    fleet_size: 50000
    cache_file: "/tmp/rollout.07.30.2025.cache"
    previous_cache_files: [ "/tmp/rollout.07.16.2025.cache" ]  # Single file as string
    preserve_previous_selections: true

For third round (and further) incremental sampling (preserving *multiple* previous selections):
  - random:
    rule:
      company-id: [256, 2865]
    sample_size: 15000
    fleet_size: 50000
    cache_file: "/tmp/campaign.07.16.2025.round3.cache"
    previous_cache_files:  # Array of files
      - "/tmp/rollout.07.16.2025.cache"
      - "/tmp/rollout.07.30.2025.cache"
    preserve_previous_selections: true

Using Perl code:

  use Cpanel::Plugins::Components::Rules::Random;
  my $config = {
      rule => {
        'company-id' => [
            7,
            256
        ]
      },
      sample_size => 1000,
      fleet_size => 50000,
      cache_file => '/tmp/campaign.07.16.2025.cache'
  };
  my $result = Cpanel::Plugins::Components::Rules::Random->new($config)->is_allowed();

  # Second round sampling preserving previous selections (single file)
  my $config_round2 = {
      rule => {
        'company-id' => [
            256,
            2865
        ]
      },
      sample_size => 10000,
      fleet_size => 50000,
      cache_file => '/tmp/rollout.07.30.2025.cache',
      previous_cache_files => '/tmp/rollout.07.16.2025.cache',  # Single file as string
      preserve_previous_selections => 1
  };
  my $result2 = Cpanel::Plugins::Components::Rules::Random->new($config_round2)->is_allowed();

  # Third round sampling preserving multiple previous selections (array)
  my $config_round3 = {
      rule => {
        'company-id' => [
            256,
            2865
        ]
      },
      sample_size => 15000,
      fleet_size => 50000,
      cache_file => '/tmp/rollout.08.01.2025.cache',
      previous_cache_files => [  # Array of files
          '/tmp/rollout.07.16.2025.cache',
          '/tmp/rollout.07.30.2025.cache'
      ],
      preserve_previous_selections => 1
  };
  my $result3 = Cpanel::Plugins::Components::Rules::Random->new($config_round3)->is_allowed();

=head1 CONFIGURATION

The Random rule expects the following configuration structure:

  {
      "rule": { ... },            # Any valid rule configuration
      "sample_size": 1000,        # Target number of servers to select
      "fleet_size": 50000,        # Optional: estimated total fleet size (default: 100000)
      "cache_file": "/path/to/cache.dat",  # Optional cache file path
      "previous_cache_files": "/path/to/cache.dat",  # Single file (string) OR array of files
      "previous_cache_files": ["/path/to/cache1.dat", "/path/to/cache2.dat"],  # Multiple files (array)
      "preserve_previous_selections": true  # Optional flag to preserve previous selections
  }

=over

=item * rule (required)

Any valid rule configuration that will be evaluated first. Only servers that
pass this rule will be considered for random selection.

=item * sample_size (required)

The target number of servers to randomly select from the pool of servers that
match the nested rule. Due to the distributed nature of the system, the actual
number selected will approximate this value.

=item * fleet_size (optional)

The estimated total number of servers in the fleet that could potentially match
the rule. Defaults to 100,000 if not specified. This is used to calculate
the selection probability (sample_size / fleet_size). More accurate fleet_size
estimates will produce better approximations of the target sample_size.

=item * cache_file (optional)

Path to a file where the random decision for this server will be cached. If
provided, the same decision will be returned on subsequent evaluations unless
the cache file is deleted or renamed.

=item * previous_cache_files (optional)

Path(s) to file(s) containing previous rounds' random selection decisions.
Must be an array of file paths.
Used in conjunction with preserve_previous_selections to implement random sampling of servers
where previously enabled servers remain enabled. If a server was selected in ANY
of these previous cache files, it will remain selected.

=item * preserve_previous_selections (optional)

Boolean flag indicating whether to preserve previous random selections. When true
and previous_cache_files is provided:
- Servers that were previously selected (enabled) remain selected
- Servers that were previously not selected or not in the previous population
  are subject to new random sampling based on sample_size/fleet_size probability

=back

=cut

=head1 METHODS

=over

=item * apply_filter -- Applies random selection logic

Determines whether the current server should be included in the random sample.
Uses cryptographically secure randomness combined with server-specific entropy
to ensure uniform distribution across the fleet.

    ARGUMENTS
        $config - Configuration hash containing sample_size, optional fleet_size, and optional cache_file

    RETURNS
        0 - Server not selected for random sample
        1 - Server selected for random sample

    ERRORS
        Dies if sample_size is not provided or invalid
        Dies if fleet_size is provided but invalid
        Dies if cache file operations fail

=back

=cut

sub apply_filter ( $self, $config ) {

    # Validate required configuration
    my $sample_size = $config->{sample_size};
    die "Random requires 'sample_size' configuration"
      unless defined $sample_size;

    die "sample_size must be a positive integer"
      unless $sample_size =~ /^\d+$/ && $sample_size > 0;

    # Validate optional fleet_size
    my $fleet_size = $config->{fleet_size} // DEFAULT_FLEET_SIZE;
    if ( defined $config->{fleet_size} ) {
        die "fleet_size must be a positive integer"
          unless $fleet_size =~ /^\d+$/ && $fleet_size > 0;

        die "fleet_size must be greater than or equal to sample_size"
          unless $fleet_size >= $sample_size;
    }

    my $cache_file           = $config->{cache_file};
    my $previous_cache_files = $config->{previous_cache_files} || [];
    die "previous_cache_files must be an array reference"
      if $previous_cache_files && ref $previous_cache_files ne 'ARRAY';
    my $preserve_previous_selections = $config->{preserve_previous_selections};

    # Check current cache first if cache file is specified
    if ($cache_file) {
        my $cached_result = $self->read_cache($cache_file);
        return int($cached_result) if defined $cached_result;
    }

    my $is_selected = 0;

    # Handle preserve previous selections logic
    if ( $preserve_previous_selections && $previous_cache_files ) {

        # Check all previous cache files for existing selections
        my $previous_result = 0;
        for my $cache_file_path (@$previous_cache_files) {
            $previous_result = $self->read_cache($cache_file_path);
            last if ( defined $previous_result && $previous_result == 1 );
        }

        if ($previous_result) {
            logger()->info("Preserving previous random selection.");
            $is_selected = 1;
        }
        else {
            # Server was not previously selected in any cache file
            # Apply new random sampling for additional servers
            logger()->info("Server was not previously selected. Applying new random selection logic.");
            $is_selected = $self->generate_random_decision( $sample_size, $fleet_size );
        }
    }
    else {
        # Standard random selection without preserving previous selections
        logger()->info("Applying standard random selection logic for new servers.");
        $is_selected = $self->generate_random_decision( $sample_size, $fleet_size );
    }

    # Cache the result if cache file is specified
    if ($cache_file) {
        $self->write_cache( $cache_file, $is_selected );
    }

    return $is_selected;
}

=head2 generate_random_decision

Generates a random decision for whether this server should be included in the
sample. Uses cryptographically secure randomness to ensure uniform distribution.

The algorithm works by generating a random value between 0 and a large number,
then checking if it falls within the selection probability range. This approach
ensures that approximately sample_size/fleet_size servers will be selected
across the entire fleet.

=over

=item ARGUMENTS

=over

=item * $sample_size - Target number of servers to select

=item * $fleet_size - Estimated total number of servers in the fleet

=back

=item RETURNS

0 or 1 - Random selection decision

=back

For more details on this algorithm see: https://webpros.atlassian.net/wiki/spaces/RD/pages/5195071896/DUCKS-3338+Random+Filter

=cut

sub generate_random_decision ( $self, $sample_size, $fleet_size ) {

    # Generate server-specific entropy for additional randomness
    my $hostname   = Cpanel::Hostname::gethostname() // 'unknown';
    my $timestamp  = time();
    my $process_id = $$;

    # Create entropy string combining multiple sources
    my $entropy_string = join( ':', $hostname, $timestamp, $process_id, rand() );

    # Generate cryptographically secure random bytes
    my $random_bytes = $self->get_secure_random_bytes(32);

    # Combine with entropy string for additional uniqueness
    my $combined_entropy = $entropy_string . $random_bytes;
    my $hash             = Digest::SHA::sha256($combined_entropy);

    # Convert first 8 bytes of hash to integer for probability calculation
    my $random_value = unpack( 'Q>', substr( $hash, 0, 8 ) );    # Q = long long int, > = big endian, See: https://perldoc.perl.org/perlpacktut

    # Calculate selection probability based on sample_size and fleet_size
    my $max_value             = 2**64 - 1;                             # Max value for 64-bit/8-byte unsigned integer
    my $selection_probability = $sample_size / $fleet_size;
    my $threshold             = $max_value * $selection_probability;

    return ( $random_value <= $threshold ) ? 1 : 0;
}

=head2 get_secure_random_bytes

Generates cryptographically secure random bytes using the best available
source on the system.

=over

=item ARGUMENTS

=over

=item * $num_bytes - Number of random bytes to generate

=back

=item RETURNS

String of random bytes

=back

=cut

sub get_secure_random_bytes ( $self, $num_bytes ) {

    # Try to read from /dev/urandom for cryptographically secure randomness
    if ( open my $fh, '<', '/dev/urandom' ) {
        my $random_bytes;
        my $bytes_read = read( $fh, $random_bytes, $num_bytes );
        close $fh;

        if ( $bytes_read == $num_bytes ) {
            return $random_bytes;
        }
    }

    # Generate a pseudo random bit of data to use in the fallback.
    # This uses Perl's rand() with multiple iterations for better entropy
    # This is less secure but still provides reasonable randomness
    my $random_bytes = '';
    for my $i ( 1 .. $num_bytes ) {

        # In the range 0 - 255, maps to character values.
        # See: https://www.geeksforgeeks.org/perl/perl-rand-function/
        $random_bytes .= chr( int( rand(256) ) );
    }

    return $random_bytes;
}

=head2 read_cache

Reads the cached decision from the specified cache file.

=over

=item ARGUMENTS

=over

=item * $cache_file - Path to cache file

=back

=item RETURNS

0, 1, or undef - Cached decision or undef if no valid cache exists

=back

=cut

sub read_cache ( $self, $cache_file ) {
    require Cpanel::Slurper;
    my $content = eval { Cpanel::Slurper::read($cache_file) };
    return unless defined $content;

    chomp $content;

    # Validate cache content
    if ( $content =~ /^[01]$/ ) {
        return int($content);
    }

    # Return undef if content is invalid
    return;
}

=head2 write_cache

Writes the decision to the specified cache file.

=over

=item ARGUMENTS

=over

=item * $cache_file - Path to cache file

=item * $decision - Decision to cache (0 or 1)

=back

=item RETURNS

1 on success

=item ERRORS

Dies if cache write fails

=back

=cut

sub write_cache ( $self, $cache_file, $decision ) {

    # Ensure decision is 0 or 1
    $decision = $decision ? 1 : 0;

    require Cpanel::FileUtils::Write;

    # Write to cache file atomically
    Cpanel::FileUtils::Write::overwrite( $cache_file, "$decision\n", 0600 );

    return 1;
}

1;

# END Augment generated code block
