<?php

namespace onespace\core\components;

use Exception;
use Faker\Generator;
use Yii;
use yii\helpers\ArrayHelper;

/**
 * @psalm-api
 * 
 * Singleton class to provide anonymisation utility. Maintains a set of Faker instances, one per locale
 * This singleton class provides a way to anonymise data in a dataset by generating psuedorandom replacement values
 * 
 * Data generation uses Faker (with a persistent instance per locale), but seeded based on either a static value or a field. 
 * For the same seed value, the outputs will be the same. The seeding is reset on each column of the data being generated, so
 * for a single row, every call of a specific method will produce the same result
 * 
 * Example Usage:
 * ```
 * $anonymiser = Anonymiser::getInstance($locale);
 * $anonymiser->anonymiseTable(
 *  'id',
 *  [
 *      'first_name' => 'firstName',
 *      'last_name' => 'lastName'
 *      'bio' => [
 *          'method' => 'words'
 *          'arguments' => [3, true]
 *      ],
 *      'fullName' => fn($row, $faker) => $faker->title() . ' ' . $faker->firstName() . ' ' . $faker->lastName(),
 *  ]
 * ```
 * )
 */
class Anonymiser {
    /**
     * Store an instance per locale
     * @var array<string, self>
     */
    private static $instances = [];

    /** @var Generator $generator */
    private $_generator;
    /**
     * @var string algorithm to use for index hashing
     */
    private static $indexAlgo = 'sha256';

    /**
     * Prevent construction outside of this class
     */
    protected function __construct(string $locale) {
        $this->_generator = \Faker\Factory::create($locale);
    }

    /**
     * Singletons should not be cloneable.
     */
    protected function __clone() {
    }

    /**
     * Singletons should not be restorable from strings.
     */
    public function __wakeup() {
        throw new \Exception("Cannot unserialize a singleton.");
    }

    /**
     * Get an instance of the anonymiser for a given locale. Instances are persisted
     * for a given locale
     */
    public static function getInstance(string $locale): self {
        if (!isset(self::$instances[$locale])) {
            self::$instances[$locale] = new self($locale);
        }

        return self::$instances[$locale];
    }


    /**
     * Seed the anonymisation generator
     * @param int|string $value Seed value to use. If a string is provided, it will be hashed and converted
     * to a compatible number internally
     * @return void 
     */
    public function seed(int|string $value) {
        if (is_string($value)) {
            $value = self::getNumberViaHash($value);
        }
        $this->_generator->seed($value);
    }

    /**
     * Convert a string value into a numeric hash suitable for seeding the generator
     * 
     * Essentially, the value is hashed in its entirety, then a substring that represents
     * 32 bits of randomisation is extracted
     * @param string $string raw value
     * @return int random number based on the hash of the string value
     */
    protected static function getNumberViaHash(string $string): int {
        $ar = hash_hmac(self::$indexAlgo, $string, '', true);

        return hexdec(substr(bin2hex($ar), 0, 8));
    }

    /**
     * Anonymise a table (2D array, list of rows) of data
     * @param int|string $seed either a static seed value, or the name of a column in the data to reference the value of for each row
     * @param array $fieldDefinitions fields to anonymise - see [[anonymiseRow()]] for format details
     * @param array<array> &$rows Table being anonymised
     * @return void 
     */
    public function anonymiseTable(int|string $seed, array $fieldDefinitions, array &$rows) {
        // simply run through the rows, anonymising each of them
        foreach ($rows as $_index => &$row) {
            $this->anonymiseRow($seed, $fieldDefinitions, $row);
        }
    }

    /**
     * Anonymise a row of data (a single entity)
     * @param int|string $seed either a static seed value, or the name of a column in the data to reference the value of
     * @param array<string, string|array|callable $fieldDefinitions fields to anonymise, in key => value format. 
     * Keys are the names of the fields; values can either be 
     * - a string for the Faker generator method name, or 
     * - an array with 2 named elements 
     *     - `method` - the name of the Faker method to use
     *     - `arguments` - an array of arguments to pass to the `method`
     * - a closure with the following signature
     *   ```
     *     function ($row, $faker) {
     *         // return the anonymised data
     *     }
     *   ```
     *   where `$row` is the current array row being processed, and `$faker` is the active Faker instance.
     *   The Faker instance can be safely re-seeded if required - it will re-seed itself to the standard seed
     *   before continuing.
     * @param array &$row row of data being anonymised
     * @return void 
     */
    public function anonymiseRow(int|string $seed, array $fieldDefinitions, array &$row) {
        // if we're seeding based on a row value, prep the seed value from that
        if (is_string($seed)) {
            /** @var int|string $seed */
            $seed = ArrayHelper::getValue($row, $seed, $this->_generator->asciify('*****'));
        }

        // run through all the fields we actually want to change - by doing it this way, we only touch the fields we're asked to
        /** @var string|array|callable $type */
        foreach ($fieldDefinitions as $attribute => $type) {
            /** @var string $attribute */
            try {
                // re-seed after each field is done, so that calling the same method repeatedly provides the same result
                $this->seed($seed);
                if (is_callable($type)) {
                    // handle custom user function
                    ArrayHelper::setValue($row, $attribute, call_user_func($type, $row, $this->_generator));
                } else if (is_array($type)) {
                    // handle array option
                    ArrayHelper::setValue($row, $attribute, $this->_generator->{$type['method']}(...ArrayHelper::getValue($type, 'arguments', [])));
                } else {
                    // handle just a string
                    ArrayHelper::setValue($row, $attribute, $this->_generator->{$type}());
                }
                $row['_anonymised'] = true;
            } catch (Exception $e) {
                Yii::error("Failed to anonymise field '$attribute' - " . $e->getMessage(), __METHOD__);
            }
        }
    }
}
