<?php

namespace onespace\core\behaviours;

use Yii;
use yii\base\Behavior;
use yii\db\ActiveRecord;
use yii\helpers\ArrayHelper;

/**
 * @psalm-api
 * 
 * EncryptionBehaviour automatically encrypts and decrypts specified fields of the model. The fields are encrypted before being saved to the database, then are
 * decrypted on retrieval.
 *
 * To use EncryptionBehaviour, insert the following code to your ActiveRecord class:
 *
 * ```php
 * use yii\behaviors\EncryptionBehaviour;
 *
 * public function behaviors()
 * {
 *     return [
 *         EncryptionBehaviour::className(),
 *     ];
 * }
 * ```
 *
 * Fields to be encrypted are set in the encryptedAttributes property.
 *
 * Encryption and decryption keys are set separately. There is a single encryption key, but multiple decryption keys can be used
 * If no encryption key is set, a fairly secure default will be used. If no decryption keys are set, only the configured encryption key will be used
 *
 * @template-extends Behavior<ActiveRecord>
 */
class EncryptionBehaviour extends Behavior {
    /**
     * @var array the attributes that will be encrypted/decrypted.
     *              Each attribute can either be:
     *              - A string, indicating an attribute name to encrypt
     *              - A key-value pair, where the key is the attribute to encrypt and the value is an attribute to store an indexable hash of the input value. The value can be one of the following:
     *              -   - a string, indicating the index attribute name. Low security will be assumed
     *              -   - an array, with the first value being the attribute name, and other options indicated as key-value pairs; options are:
     *              -   -   - 'secure' - whether to use higher-security, but more costly, PBKDF2 to generate the index, rather than the standard HMAC
     */
    public $attributes = [];

    /**
     * @var array attributes that will be encrypted
     */
    private $encryptAttributes = [];
    /**
     * @var array attributes to be used to index encrypted attributes
     */
    private $indexAttributes = [];

    /**
     * @var string the key to use to encypt the fields.
     * Default is a 64-char random key, but can be set to any string value
     */
    public $encryptKey = 'IXufJhSWvWJ.7#c|X]AfWKKHjrQA2X7{s*RR|WSQr\Vy.KgF:O~Lru\>x\jr*\*I';

    /**
     * @var string the key to use to salt the indexable fields. SHOULD BE DIFFERENT TO ENCRYPTION KEYS
     * Default is a 64-char random key, but can be set to any string value
     */
    public $indexKey = 'IK9z_Gv1502IRzz3sgcp2hzg2jUBvaTbvQVzlJG3ySqipoaWg0Z8ysd9G0o7Gs7R';

    /**
     * Keys that can be used to decypt the fields.
     * If not set, it will use the encryptKey value only
     */
    public array $decryptKeys = [];

    public bool $autoEncrypt = true;
    public bool $autoDecrypt = true;

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

    /**
     * @param ActiveRecord $owner
     * @return void
     */
    #[\Override]
    public function attach($owner) {
        // If no decryption key is set, use the encryption key
        if (count($this->decryptKeys) == 0) {
            $this->decryptKeys = [$this->encryptKey];
        }
        // for each attribute, check if it is in format encrypt=>index
        foreach ($this->attributes as $key => $value) {
            if (!is_string($key)) {
                // this is just an attribute, add the value to the encryption attributes
                $this->encryptAttributes[] = $value;
            } else {
                // this is in encrypt=>index format. Add the key to the encryption attributes and the value to the index attributes
                $this->encryptAttributes[] = $key;
                $this->indexAttributes[$key] = $value;
            }
        }
        parent::attach($owner);
    }

    /**
     * {@inheritdoc}
     */
    #[\Override]
    public function events() {
        return [
            ActiveRecord::EVENT_BEFORE_INSERT => 'beforeSave',
            ActiveRecord::EVENT_BEFORE_UPDATE => 'beforeSave',
            ActiveRecord::EVENT_AFTER_FIND => 'afterFind',
        ];
    }

    /**
     * Perform the encryption of the model with the specified key
     */
    public function encryptModel(string $key): void {
        foreach ($this->encryptAttributes as $attribute) {
            // If the model is successfully encrypted, it shouldn't be automatically encrypted again before saving
            if (isset($this->owner->$attribute) && $this->owner->$attribute != null) {
                // hang onto the plaintext in case we need to index it
                $plaintext = $this->owner->$attribute;
                // perform encryption on attribute
                $this->owner->$attribute = base64_encode(Yii::$app->security->encryptByKey($this->owner->$attribute, $key));

                // check whether we need to index this attribute
                if (isset($this->indexAttributes[$attribute])) {
                    $this->getIndexValueForAttribute($attribute, $plaintext, true);
                }
                $this->autoEncrypt = false;
            }
        }
    }

    /**
     * Perform the decryption given a list of possible decryption keys
     */
    public function decryptModel(array $keys): void {
        foreach ($this->encryptAttributes as $attribute) {
            if (isset($this->owner->$attribute) && $this->owner->$attribute != null) {
                // iterate through the decryption keys until we find one that works
                foreach ($keys as $key) {
                    // if the decryption succeeds, store the result and break out of the loop to go to the next attribute
                    $decrypted = Yii::$app->security->decryptByKey(base64_decode((string) $this->owner->$attribute), $key);
                    if ($decrypted !== false) {
                        $this->owner->$attribute = $decrypted;
                        break;
                    }
                }
            }
        }
    }

    /**
     * Get a value that can be used to search the table for matching records
     *
     * @param string $attribute Name of the attribute to generate index for
     * @param string $value Value to index
     * @param bool $store Whether to store the generated value in the model
     *
     * @return null|string
     */
    public function getIndexValueForAttribute($attribute, $value, $store = false): string|null {
        // we can only return if this attribute is configured for indexing
        if (isset($this->indexAttributes[$attribute])) {
            // using method outlined in https://paragonie.com/blog/2017/05/building-searchable-encrypted-databases-with-php-and-sql#solution-literal-search, we need to index this value
            $indexValue = $this->indexAttributes[$attribute];
            // get the config for this index
            $secure = false;
            $indexAttr = null;
            if (is_string($indexValue)) {
                $indexAttr = $indexValue;
            } elseif (is_array($indexValue)) {
                $indexAttr = $indexValue[0];
                $secure = (bool)ArrayHelper::getValue($indexValue, 'secure', false);
            }
            // we use different algos depending on whether we want to be faster or more secure
            if ($secure) {
                // use PBKDF2
                $index = Yii::$app->security->pbkdf2($this->indexAlgo, $value, $this->indexKey, 10000);
            } else {
                // use HMAC
                $index = hash_hmac($this->indexAlgo, $value, $this->indexKey, false);
            }

            // if set to store the value, put it in the relevant attribute
            if ($store && isset($indexAttr) && isset($this->owner)) {
                // store in b64 in index
                $this->owner->$indexAttr = base64_encode($index);
            }

            // return as b64
            return base64_encode($index);
        } else {
            return null;
        }
    }

    /**
     * Encrypts all of the fields required
     */
    public function beforeSave(): void {
        if ($this->autoEncrypt) {
            $this->encryptModel($this->encryptKey);
        }
    }


    /**
     * 
     * Decrypts all of the fields required
     */
    public function afterFind(): void {
        if ($this->autoDecrypt) {
            $this->decryptModel($this->decryptKeys);
        }
    }
}
