Completed
Push — master ( 7fff96...157abc )
by Jordan
25s queued 13s
created

RandomProvider   A

Complexity

Total Complexity 40

Size/Duplication

Total Lines 398
Duplicated Lines 0 %

Test Coverage

Coverage 73.32%

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 119
c 1
b 0
f 0
dl 0
loc 398
ccs 77
cts 105
cp 0.7332
rs 9.2
wmc 40

3 Methods

Rating   Name   Duplication   Size   Complexity  
C randomReal() 0 172 16
A randomDecimal() 0 29 4
D randomInt() 0 160 20

How to fix   Complexity   

Complex Class

Complex classes like RandomProvider often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use RandomProvider, and based on these observations, apply Extract Interface, too.

1
<?php
2
3
namespace Samsara\Fermat\Provider;
4
5
use Exception;
6
use JetBrains\PhpStorm\ExpectedValues;
7
use Samsara\Exceptions\UsageError\IntegrityConstraint;
8
use Samsara\Exceptions\SystemError\LogicalError\IncompatibleObjectState;
9
use Samsara\Exceptions\UsageError\OptionalExit;
10
use Samsara\Fermat\Enums\NumberBase;
11
use Samsara\Fermat\Enums\RandomMode;
12
use Samsara\Fermat\Numbers;
13
use Samsara\Fermat\Types\Base\Interfaces\Numbers\DecimalInterface;
14
use Samsara\Fermat\Values\ImmutableDecimal;
15
16
/**
17
 *
18
 */
19
class RandomProvider
20
{
21
22
    /** @noinspection PhpDocMissingThrowsInspection */
23
    /**
24
     * @param int|string|DecimalInterface $min
25
     * @param int|string|DecimalInterface $max
26
     * @param RandomMode $mode
27
     * @return ImmutableDecimal
28
     * @throws IntegrityConstraint
29
     * @throws OptionalExit
30
     * @throws IncompatibleObjectState
31
     */
32 12
    public static function randomInt(
33
        int|string|DecimalInterface $min,
34
        int|string|DecimalInterface $max,
35
        RandomMode $mode = RandomMode::Entropy
36
    ): ImmutableDecimal
37
    {
38 12
        $minDecimal = Numbers::makeOrDont(Numbers::IMMUTABLE, $min);
39 12
        $maxDecimal = Numbers::makeOrDont(Numbers::IMMUTABLE, $max);
40
41
        /**
42
         * We want to prevent providing non-integer values for min and max, even in cases where
43
         * the supplied value is a string or a DecimalInterface.
44
         */
45 12
        if ($minDecimal->isFloat() || $maxDecimal->isFloat()) {
0 ignored issues
show
Bug introduced by
The method isFloat() does not exist on Samsara\Fermat\Types\Bas...Numbers\NumberInterface. It seems like you code against a sub-type of Samsara\Fermat\Types\Bas...Numbers\NumberInterface such as Samsara\Fermat\Types\Bas...umbers\DecimalInterface or Samsara\Fermat\Types\Decimal. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

45
        if ($minDecimal->/** @scrutinizer ignore-call */ isFloat() || $maxDecimal->isFloat()) {
Loading history...
46
            throw new IntegrityConstraint(
47
                'Random integers cannot be generated with boundaries which are floats',
48
                'Provide only whole number, integer values for min and max.',
49
                'An attempt was made to generate a random integer with boundaries which are non-integers. Min Provided: '.$min->getValue(NumberBase::Ten).' -- Max Provided: '.$max->getValue(NumberBase::Ten)
0 ignored issues
show
Unused Code introduced by
The call to Samsara\Fermat\Types\Bas...erInterface::getValue() has too many arguments starting with Samsara\Fermat\Enums\NumberBase::Ten. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

49
                'An attempt was made to generate a random integer with boundaries which are non-integers. Min Provided: '.$min->/** @scrutinizer ignore-call */ getValue(NumberBase::Ten).' -- Max Provided: '.$max->getValue(NumberBase::Ten)

This check compares calls to functions or methods with their respective definitions. If the call has more arguments than are defined, it raises an issue.

If a function is defined several times with a different number of parameters, the check may pick up the wrong definition and report false positives. One codebase where this has been known to happen is Wordpress. Please note the @ignore annotation hint above.

Loading history...
50
            );
51
        }
52
53
        /**
54
         * Because of optimistic optimizing with the rand() and random_int() functions, we do
55
         * need the arguments to be provided in the correct order.
56
         */
57 12
        if ($minDecimal->isGreaterThan($maxDecimal)) {
0 ignored issues
show
Bug introduced by
The method isGreaterThan() does not exist on Samsara\Fermat\Types\Bas...Numbers\NumberInterface. It seems like you code against a sub-type of said class. However, the method does not exist in Samsara\Fermat\Types\Base\Number. Are you sure you never get one of those? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

57
        if ($minDecimal->/** @scrutinizer ignore-call */ isGreaterThan($maxDecimal)) {
Loading history...
58
            throw new IntegrityConstraint(
59
                'Minimum is larger than maximum.',
60
                'Please provide your arguments in the correct order.',
61
                'The supplied minimum value for randomInt() was greater than the supplied maximum value.'
62
            );
63
        }
64
65
        /**
66
         * For some applications it might be better to throw an exception here, however it
67
         * would probably be hard to recover in most applications from a situation which
68
         * resulted in this situation.
69
         *
70
         * So instead we will trigger a language level warning and return the only valid
71
         * value for the parameters given.
72
         */
73 12
        if ($minDecimal->isEqual($maxDecimal)) {
74 1
            trigger_error(
75 1
                'Attempted to get a random value for a range of no size, with minimum of '.$minDecimal->getValue(NumberBase::Ten).' and maximum of '.$maxDecimal->getValue(NumberBase::Ten),
0 ignored issues
show
Unused Code introduced by
The call to Samsara\Fermat\Types\Bas...erInterface::getValue() has too many arguments starting with Samsara\Fermat\Enums\NumberBase::Ten. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

75
                'Attempted to get a random value for a range of no size, with minimum of '.$minDecimal->/** @scrutinizer ignore-call */ getValue(NumberBase::Ten).' and maximum of '.$maxDecimal->getValue(NumberBase::Ten),

This check compares calls to functions or methods with their respective definitions. If the call has more arguments than are defined, it raises an issue.

If a function is defined several times with a different number of parameters, the check may pick up the wrong definition and report false positives. One codebase where this has been known to happen is Wordpress. Please note the @ignore annotation hint above.

Loading history...
76
                E_USER_WARNING
77
            );
78
79
            return $minDecimal;
80
        }
81
82 11
        if ($minDecimal->isLessThanOrEqualTo(PHP_INT_MAX) && $minDecimal->isGreaterThanOrEqualTo(PHP_INT_MIN)) {
0 ignored issues
show
Bug introduced by
The method isGreaterThanOrEqualTo() does not exist on Samsara\Fermat\Types\Bas...Numbers\NumberInterface. It seems like you code against a sub-type of said class. However, the method does not exist in Samsara\Fermat\Types\Base\Number. Are you sure you never get one of those? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

82
        if ($minDecimal->isLessThanOrEqualTo(PHP_INT_MAX) && $minDecimal->/** @scrutinizer ignore-call */ isGreaterThanOrEqualTo(PHP_INT_MIN)) {
Loading history...
Bug introduced by
The method isLessThanOrEqualTo() does not exist on Samsara\Fermat\Types\Bas...Numbers\NumberInterface. It seems like you code against a sub-type of said class. However, the method does not exist in Samsara\Fermat\Types\Base\Number. Are you sure you never get one of those? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

82
        if ($minDecimal->/** @scrutinizer ignore-call */ isLessThanOrEqualTo(PHP_INT_MAX) && $minDecimal->isGreaterThanOrEqualTo(PHP_INT_MIN)) {
Loading history...
83
            /** @noinspection PhpUnhandledExceptionInspection */
84 11
            $min = $minDecimal->asInt();
0 ignored issues
show
Bug introduced by
The method asInt() does not exist on Samsara\Fermat\Types\Bas...Numbers\NumberInterface. It seems like you code against a sub-type of Samsara\Fermat\Types\Bas...Numbers\NumberInterface such as Samsara\Fermat\Types\Bas...umbers\DecimalInterface or Samsara\Fermat\Types\Decimal. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

84
            /** @scrutinizer ignore-call */ 
85
            $min = $minDecimal->asInt();
Loading history...
85
        }
86
87 11
        if ($maxDecimal->isLessThanOrEqualTo(PHP_INT_MAX) && $maxDecimal->isGreaterThanOrEqualTo(PHP_INT_MIN)) {
88
            /** @noinspection PhpUnhandledExceptionInspection */
89 10
            $max = $maxDecimal->asInt();
90
        }
91
92 11
        if (is_int($min) && is_int($max)) {
93 10
            if ($mode == RandomMode::Entropy || $max > getrandmax() || $max < 0 || $min < 0) {
94
                /**
95
                 * The random_int() function is cryptographically secure, and takes somewhere on the order
96
                 * of 15 times as long to execute as rand(). However, rand() also has a smaller range than
97
                 * the entire PHP integer size, so there are some situations where we need to use this
98
                 * function even if MODE_SPEED is selected.
99
                 *
100
                 * In those cases, random_int() is still faster than calls to random_bytes() and manual
101
                 * masking.
102
                 */
103
                try {
104 8
                    $num = random_int($min, $max);
105 8
                    return new ImmutableDecimal($num);
106
                } catch (Exception $e) {
107
                    throw new OptionalExit(
108
                        'System error from random_bytes().',
109
                        'Ensure your system is configured correctly.',
110
                        'A call to random_bytes() threw a system level exception. Most often this is due to a problem with entropy sources in your configuration. Original exception message: ' . $e->getMessage()
111
                    );
112
                }
113 4
            } elseif ($mode == RandomMode::Speed) {
114
                /**
115
                 * If it is possible to do so with the range given and the program has indicated that it
116
                 * would prefer speed to true randomness in the result, then we will use the deterministic
117
                 * pseudo-random function rand() as it is faster to reach completion.
118
                 */
119 4
                $num = rand($min, $max);
120 4
                return new ImmutableDecimal($num);
121
            } else {
122
                throw new IntegrityConstraint(
123
                    'Mode on random functions must be an implemented mode.',
124
                    'Choose modes using the class constants.',
125
                    'A mode was provided to randomInt() that does not correspond to any implementation. Please only use the class constants for selecting the mode.'
126
                );
127
            }
128
        } else {
129
            /**
130
             * We only need to request enough bytes to find a number within the range, since we
131
             * will be adding the minimum value to it at the end.
132
             */
133
            /** @noinspection PhpUnhandledExceptionInspection */
134 1
            $range = $maxDecimal->subtract($minDecimal);
135
            /** @noinspection PhpUnhandledExceptionInspection */
136 1
            $bitsNeeded = $range->ln(2)->divide(Numbers::makeNaturalLog2(2), 2)->floor()->add(1);
0 ignored issues
show
Bug introduced by
The method floor() does not exist on Samsara\Fermat\Types\Bas...mbers\FractionInterface. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

136
            $bitsNeeded = $range->ln(2)->divide(Numbers::makeNaturalLog2(2), 2)->/** @scrutinizer ignore-call */ floor()->add(1);

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
Bug introduced by
The method ln() does not exist on Samsara\Fermat\Types\Bas...mbers\FractionInterface. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

136
            $bitsNeeded = $range->/** @scrutinizer ignore-call */ ln(2)->divide(Numbers::makeNaturalLog2(2), 2)->floor()->add(1);

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
Bug introduced by
The method ln() does not exist on Samsara\Fermat\Types\Bas...Numbers\NumberInterface. It seems like you code against a sub-type of Samsara\Fermat\Types\Bas...Numbers\NumberInterface such as Samsara\Fermat\Types\Bas...umbers\DecimalInterface or Samsara\Fermat\Types\Decimal. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

136
            $bitsNeeded = $range->/** @scrutinizer ignore-call */ ln(2)->divide(Numbers::makeNaturalLog2(2), 2)->floor()->add(1);
Loading history...
Bug introduced by
The method floor() does not exist on Samsara\Fermat\Types\Bas...Numbers\NumberInterface. It seems like you code against a sub-type of Samsara\Fermat\Types\Bas...Numbers\NumberInterface such as Samsara\Fermat\Types\Bas...umbers\DecimalInterface or Samsara\Fermat\Types\Decimal. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

136
            $bitsNeeded = $range->ln(2)->divide(Numbers::makeNaturalLog2(2), 2)->/** @scrutinizer ignore-call */ floor()->add(1);
Loading history...
Bug introduced by
The method ln() does not exist on Samsara\Fermat\Types\Fraction. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

136
            $bitsNeeded = $range->/** @scrutinizer ignore-call */ ln(2)->divide(Numbers::makeNaturalLog2(2), 2)->floor()->add(1);

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
137 1
            $bytesNeeded = $bitsNeeded->divide(8, 2)->ceil();
0 ignored issues
show
Bug introduced by
The method ceil() does not exist on Samsara\Fermat\Types\Bas...mbers\FractionInterface. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

137
            $bytesNeeded = $bitsNeeded->divide(8, 2)->/** @scrutinizer ignore-call */ ceil();

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
Bug introduced by
The method ceil() does not exist on Samsara\Fermat\Types\Bas...Numbers\NumberInterface. It seems like you code against a sub-type of Samsara\Fermat\Types\Bas...Numbers\NumberInterface such as Samsara\Fermat\Types\Bas...umbers\DecimalInterface or Samsara\Fermat\Types\Decimal. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

137
            $bytesNeeded = $bitsNeeded->divide(8, 2)->/** @scrutinizer ignore-call */ ceil();
Loading history...
138
139
            do {
140
                try {
141
                    /**
142
                     * Returns random bytes based on sources of entropy within the system.
143
                     *
144
                     * For documentation on these sources please see:
145
                     *
146
                     * https://www.php.net/manual/en/function.random-bytes.php
147
                     */
148 1
                    $entropyBytes = random_bytes($bytesNeeded->asInt());
149 1
                    $baseTwoBytes = '';
150 1
                    for($i = 0; $i < strlen($entropyBytes); $i++){
151 1
                        $baseTwoBytes .= decbin( ord( $entropyBytes[$i] ) );
152
                    }
153
                } catch (Exception $e) {
154
                    throw new OptionalExit(
155
                        'System error from random_bytes().',
156
                        'Ensure your system is configured correctly.',
157
                        'A call to random_bytes() threw a system level exception. Most often this is due to a problem with entropy sources in your configuration. Original exception message: ' . $e->getMessage()
158
                    );
159
                }
160
161 1
                $randomValue = BaseConversionProvider::convertStringToBaseTen($baseTwoBytes, NumberBase::Two);
162
163
                /**
164
                 * @var ImmutableDecimal $num
165
                 *
166
                 * Since the number of digits is equal to the bits needed, but random_bytes() only
167
                 * returns in chunks of 8 bits (duh, bytes), we can substr() from the right to
168
                 * select only the correct number of digits by multiplying the number of bits
169
                 * needed by -1 and using that as the starting point.
170
                 */
171 1
                $num = Numbers::make(
172
                    type: Numbers::IMMUTABLE,
173 1
                    value: substr($randomValue, $bitsNeeded->multiply(-1)->asInt())
0 ignored issues
show
Bug introduced by
The method asInt() does not exist on Samsara\Fermat\Types\Bas...mbers\FractionInterface. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

173
                    value: substr($randomValue, $bitsNeeded->multiply(-1)->/** @scrutinizer ignore-call */ asInt())

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
174
                );
175 1
            } while ($num->isGreaterThan($range));
176
            /**
177
             * It is strictly speaking possible for this to loop infinitely. In the worst case
178
             * scenario where 50% of possible values are invalid, it takes 7 loops for there to
179
             * be a less than a 1% chance of still not having an answer.
180
             *
181
             * After only 10 loops the chance is less than 1/1000.
182
             */
183
184
            /**
185
             * Add the minimum since we effectively subtracted it by finding a random number
186
             * bounded between 0 and range. If our requested range included negative numbers,
187
             * this operation will also return those values into our data by effectively
188
             * shifting the result window.
189
             */
190
            /** @noinspection PhpUnhandledExceptionInspection */
191 1
            return $num->add($minDecimal);
192
        }
193
    }
194
195
    /**
196
     * @param int $scale
197
     * @param RandomMode $mode
198
     * @return ImmutableDecimal
199
     * @throws IntegrityConstraint
200
     * @throws OptionalExit
201
     * @throws IncompatibleObjectState
202
     */
203 2
    public static function randomDecimal(
204
        int $scale = 10,
205
        RandomMode $mode = RandomMode::Entropy
206
    ): ImmutableDecimal
207
    {
208
        /**
209
         * Select the min and max as if we were looking for the decimal part as an integer.
210
         */
211 2
        $min = new ImmutableDecimal(0);
212 2
        $max = new ImmutableDecimal(str_pad('1', $scale+1, '0'));
213
214
        /**
215
         * This allows us to utilize the same randomInt() function.
216
         */
217 2
        $randomValue = self::randomInt($min, $max, $mode);
218
219
        /**
220
         * If the random value exactly equals our min or max, that means we need to return
221
         * either 1 or 0.
222
         */
223 2
        if ($randomValue->isEqual($min) || $randomValue->isEqual($max)) {
224
            return $randomValue->isPositive() ? new ImmutableDecimal(1) : $min;
225
        }
226
227
        /**
228
         * In all other cases we need to reformat our integer as being the decimal portion
229
         * of our number at the given scale.
230
         */
231 2
        return new ImmutableDecimal('0.'.str_pad($randomValue->getValue(NumberBase::Ten), $scale, '0', STR_PAD_LEFT));
232
    }
233
234
    /** @noinspection PhpDocMissingThrowsInspection */
235
    /**
236
     * @param int|string|DecimalInterface $min
237
     * @param int|string|DecimalInterface $max
238
     * @param int $scale
239
     * @param RandomMode $mode
240
     * @return ImmutableDecimal
241
     * @throws IntegrityConstraint
242
     * @throws OptionalExit
243
     * @throws IncompatibleObjectState
244
     */
245 3
    public static function randomReal(
246
        int|string|DecimalInterface $min,
247
        int|string|DecimalInterface $max,
248
        int $scale,
249
        RandomMode $mode = RandomMode::Entropy
250
    ): ImmutableDecimal
251
    {
252 3
        $min = new ImmutableDecimal($min);
253 3
        $max = new ImmutableDecimal($max);
254
255 3
        if ($min->isEqual($max)) {
256
            trigger_error(
257
                'Attempted to get a random value for a range of no size, with minimum of '.$min->getValue(NumberBase::Ten).' and maximum of '.$max->getValue(NumberBase::Ten),
258
                E_USER_WARNING
259
            );
260
261
            return $min;
262
        }
263
264
        /**
265
         * We do this because randomDecimal() can return 1, so if max is a natural number we need to
266
         * remove it from the result set. Otherwise, we would be grabbing extra values and be shifting
267
         * them to somewhere else in the result set, which skews the relative probabilities.
268
         */
269 3
        if ($max->isNatural()) {
270
            /** @noinspection PhpUnhandledExceptionInspection */
271
            $maxIntRange = $max->subtract(1);
272
        } else {
273 3
            $maxIntRange = $max->floor();
274
        }
275
276 3
        if (!$min->floor()->isEqual($maxIntRange)) {
277 2
            $intPart = self::randomInt($min->floor(), $maxIntRange, $mode);
278 2
            $repeatProbability = Numbers::makeZero();
279
280
            /**
281
             * If min and max aren't bounded by the same integers, then we need to adjust the likelihood
282
             * of an integer on the ends of the range being selected according to the percentage of
283
             * numbers within that range which are available.
284
             */
285 2
            if ($min->ceil()->isEqual($max->floor())) {
286
                /**
287
                 * This is a special case where min and max are less than 1 apart, but they straddle an
288
                 * integer. In this case, we want to consider the relative likelihood, instead of the
289
                 * portion of real numbers available.
290
                 */
291 1
                $minCeil = $min->ceil();
292 1
                $minRepeat = $minCeil->subtract($min);
293 1
                $maxFloor = $max->floor();
294
                /** @noinspection PhpUnhandledExceptionInspection */
295 1
                $maxRepeat = $max->subtract($maxFloor);
296 1
                $one = Numbers::makeOne(10);
297
298
                /** @noinspection PhpUnhandledExceptionInspection */
299 1
                $repeatProbability = $one->subtract($maxRepeat->divide($minRepeat, 10));
300 1
            } elseif ($intPart->isEqual($min->floor())) {
301
                /**
302
                 * In this case, the integer includes the min. Since it's possible that not all reals
303
                 * in this range are actually available to choose from, the likelihood that this integer
304
                 * was chosen relative to any other integer in the range can be adjusted by making a
305
                 * recursive call with probability X, where X is min - floor(min).
306
                 */
307 1
                $minFloor = $min->floor();
308
                /** @noinspection PhpUnhandledExceptionInspection */
309 1
                $repeatProbability = $min->subtract($minFloor);
310 1
            } elseif ($intPart->isEqual($max->floor())) {
311
                /**
312
                 * In this case, the integer includes the max. Since it's possible that not all reals
313
                 * in this range are actually available to choose from, the likelihood that this integer
314
                 * was chosen relative to any other integer in the range can be adjusted by making a
315
                 * recursive call with probability X, where X is ceil(max) - max.
316
                 */
317 1
                $maxCeil = $max->ceil();
318 1
                $repeatProbability = $maxCeil->subtract($max);
319
            }
320
321
            /**
322
             * This will never be true unless one of the special cases above occurred. We use short circuiting
323
             * to prevent a needless additional random generation in situations where there is zero probability
324
             * adjustment.
325
             */
326 2
            if ($repeatProbability->isGreaterThan(0) && $repeatProbability->isGreaterThan(self::randomDecimal(10, $mode))) {
327 2
                return self::randomReal($min, $max, $scale, $mode);
328
            }
329
        } else {
330
            /**
331
             * In the case where min and max are bounded by the same integers, we can just set the integer
332
             * part to floor(min) without any further calculation. All of the randomness of the value will
333
             * come from the decimal part.
334
             */
335 1
            $intPart = $min->floor();
336
        }
337
338 3
        if (!$intPart->isEqual($max->floor()) && !$intPart->isEqual($min->floor())) {
339
            /**
340
             * Because we know at this point that min and max are not equal prior to the conditions in
341
             * this statement, we can be certain that the entire decimal range is available for selection
342
             * if it passes these checks.
343
             *
344
             * The situations in which the entire decimal is a valid part of the result set are all covered
345
             * by checking that intPart isn't equal to the floor of either min or max, since those are the only
346
             * two integers which have bounded decimal ranges.
347
             */
348 1
            $decPart = self::randomDecimal($scale, $mode);
349
        } else {
350 3
            if ($min->isNatural() || $intPart->isGreaterThan($min->floor())) {
351
                /**
352
                 * The greater than check is also true any time min is a natural number (integer), however the check
353
                 * for min being an integer is much faster, so we're taking advantage of short circuiting.
354
                 */
355 2
                $minDecimal = Numbers::makeZero();
356
            } else {
357
                /**
358
                 * The min is guaranteed to have a decimal portion here, since we already checked if it's natural.
359
                 *
360
                 * First we use string manipulation to extract the decimal portion as an integer value, the we right
361
                 * pad with zeroes to make sure that the entire scale is part of the valid result set.
362
                 */
363 3
                $minDecimal = $min->getDecimalPart();
364 3
                $minDecimal = str_pad($minDecimal, $scale, '0');
365
            }
366
367 3
            if ($intPart->isLessThan($max->floor())) {
368
                /**
369
                 * We cannot take advantage of a more efficient check for the top end of the range, so the
370
                 * less than check is all we need.
371
                 */
372 2
                $maxDecimal = str_pad('1', $scale + 1, '0');
373
            } else {
374
                /**
375
                 * The max value is guaranteed to have a decimal portion here since we excluded max being
376
                 * a natural number and part of the result set for intPart.
377
                 *
378
                 * First we use string manipulation to extract the decimal portion as an integer value, the we right
379
                 * pad with zeroes to make sure that the entire scale is part of the valid result set.
380
                 */
381 3
                $maxDecimal = $max->getDecimalPart();
382 3
                $maxDecimal = str_pad($maxDecimal, $scale, '0');
383
            }
384
385
            /**
386
             * Now that we have the correct bounds for the integers we're bounded by, figure out what the decimal
387
             * portion of the random number is by utilizing randomInt().
388
             */
389 3
            $decPartAsInt = self::randomInt($minDecimal, $maxDecimal, $mode);
390
391 3
            if ($decPartAsInt->isEqual($maxDecimal) && strlen($maxDecimal) > $scale) {
392
                /**
393
                 * In the case where maxDecimal was returned by randomInt, we want to specifically translate
394
                 * that to 1 instead of treating it as a decimal value. But that's only the case if maxDecimal
395
                 * was larger than our scale.
396
                 *
397
                 * This is another case of us using short circuiting on a more efficient call.
398
                 */
399
                $decPart = Numbers::makeOne($scale);
400
            } else {
401
                /**
402
                 * In this section we know with certainty that the result of randomInt represents a decimal value
403
                 * that we can simply append as a string with padding to ensure correct scale.
404
                 */
405 3
                $decPart = new ImmutableDecimal(
406 3
                    value: '0.'.str_pad($decPartAsInt->getValue(NumberBase::Ten), $scale, '0', STR_PAD_LEFT),
407
                    scale: $scale
408
                );
409
            }
410
        }
411
412
        /**
413
         * Combine the integer and decimal portions of the random value.
414
         */
415
        /** @noinspection PhpUnhandledExceptionInspection */
416 3
        return $intPart->add($decPart);
417
    }
418
419
}