<?php

namespace onespace\tools\rabbit\behaviors;

use Closure;
use Exception;
use InvalidArgumentException;
use onespace\tools\rabbit\components\Rabbit;
use Yii;
use yii\base\Behavior;
use yii\db\ActiveQuery;
use yii\db\ActiveRecord;
use yii\db\AfterSaveEvent;
use yii\db\BaseActiveRecord;
use yii\db\Connection;
use yii\db\Exception as DbException;
use yii\db\Transaction;
use yii\helpers\ArrayHelper;
use yii\helpers\Inflector;
use yii\helpers\Json;

/**
 * @property ActiveRecord $owner
 * @property Rabbit $rabbitComponent
 */
class RabbitEventBehavior extends Behavior {
    public const EVENT_INSERT = 'insert';
    public const EVENT_UPDATE = 'update';
    public const EVENT_DELETE = 'delete';

    /**
     * Whether or not the behaviour should run
     */
    public $enabled = true;

    /**
     * Whether or not to run in debug mode
     */
    public $debug = null;

    /**
     * Whether this entity is to be live-synchronised (emits events automatically after save etc) or manually synced
     */
    public $liveSync = true;

    /**
     * Instance of rabbit component to use to send the events
     * @property Rabbit $rabbitComponent
     */
    public $rabbitComponent = null;

    /**
     * Name of the entity to broadcast as. Will be used in exchange name and within payload
     */
    public $entityName = '';

    /**
     * Name of the exchange. If not set, will be the same as the `entityName`
     */
    public $exchangeName = '';

    /**
     * Class to use to pull author information - if null, will use `Yii::$app->user->identity` if it exists. If false, no author will be supplied
     */
    public $author = null;

    /**
     * Attributes of author to use for author details.
     * Can be attribute name, array of attribute names, or associative array of `attribute name` => `name in message`
     */
    public $authorAttributes = [];

    /**
     * Exclude specific attributes
     */
    public $excludeAttributes = [];

    /**
     * Allow including only specific attributes
     */
    public $includeAttributes = [];

    /**
     * Define attributes constructed from other ones to include, in format source => constructed. If the source attribute is modified, the constructed attributes will be included
     */
    public $constructedAttributes = [];

    /**
     * Include related entities by relation name
     */
    public $includeRelations = [];

    /**
     * Allow aliases of primary keys
     */
    public $primaryKey = [];

    /**
     * Merge with other model(s) into a single entity
     * @var ActiveQuery[] $mergeWith
     */
    public $mergeWith = [];

    /**
     * @var ActiveQuery $childOf
     */
    public $childOf = null;
    private $parent = null;
    private $attributeCache = null;

    /**
     * List of events to run this for. Currently supports `BaseActiveRecord::EVENT_AFTER_INSERT`, `BaseActiveRecord::EVENT_AFTER_UPDATE`, and `BaseActiveRecord::EVENT_AFTER_DELETE`
     */
    public $events = [
        self::EVENT_INSERT,
        self::EVENT_UPDATE,
        self::EVENT_DELETE,
    ];

    /**
     * Attributes to ignore changes of. If a save involves ONLY these attributes (or a subset thereof), no message will be sent
     */
    public $ignoreUpdateAttributes = null;

    /**
     * Whether to force columns with underscores in their names to camel case
     */
    public $enforceCamelCase = false;

    /**
     * Whether to use db transactions to maintain atomicity - ie, if sending to rabbit fails, undo the change locally.
     * Is ignored if using outbox.
     */
    public $atomic = true;

    /**
     * Whether or not to use a rabbit outbox. Saves the rabbit query to a outbox table
     * where a listener picks it up and sends it on.
     */
    public bool $useOutbox = true;

    /**
     * Any additional information to be passed to the rabbit query. Should
     * be avoided if possible.
     *
     * If a `Closure` is passed, it should always return an array.
     */
    public Closure|array|null $extraData = null;

    /**
     * @var Transaction|null $transaction
     */
    private $transaction = null;

    /**
     * Whether or not to send all model attributes on
     * update and delete events.
     * @var bool
     */
    public bool $sendModelAttributes = false;

    /**
     * {@inheritdoc}
     */
    public function events() {
        if (!$this->liveSync) {
            return [];
        }

        if ($this->childOf != null) {
            $supportedEvents = [
                self::EVENT_INSERT => [BaseActiveRecord::EVENT_AFTER_INSERT => 'afterInsertAsChild', BaseActiveRecord::EVENT_BEFORE_INSERT => 'prepareTransactionAsChild'],
                self::EVENT_UPDATE => [BaseActiveRecord::EVENT_AFTER_UPDATE => 'afterUpdateAsChild', BaseActiveRecord::EVENT_BEFORE_UPDATE => 'prepareTransactionAsChild'],
                self::EVENT_DELETE => [BaseActiveRecord::EVENT_AFTER_DELETE => 'afterDeleteAsChild', BaseActiveRecord::EVENT_BEFORE_DELETE => 'prepareTransactionForDeletionAsChild'],
            ];
        } else {
            $supportedEvents = [
                self::EVENT_INSERT => [BaseActiveRecord::EVENT_AFTER_INSERT => 'afterInsert', BaseActiveRecord::EVENT_BEFORE_INSERT => 'prepareTransaction'],
                self::EVENT_UPDATE => [BaseActiveRecord::EVENT_AFTER_UPDATE => 'afterUpdate', BaseActiveRecord::EVENT_BEFORE_UPDATE => 'prepareTransaction'],
                self::EVENT_DELETE => [BaseActiveRecord::EVENT_AFTER_DELETE => 'afterDelete', BaseActiveRecord::EVENT_BEFORE_DELETE => 'prepareTransaction'],
            ];
        }

        // filter the supported events by the list provided
        $activeEvents = [];
        foreach ($this->events as $enabledEvent) {
            if (key_exists($enabledEvent, $supportedEvents)) {
                foreach ($supportedEvents[$enabledEvent] as $event => $action) {
                    $activeEvents[$event] = $action;
                }
            }
        }

        return $activeEvents;
    }

    /**
     * @inheritdoc
     */
    public function attach($owner) {
        // make sure we're being attached to a compatible model
        if ($owner instanceof ActiveRecord) {
            parent::attach($owner);
        } else {
            throw new InvalidArgumentException("Rabbit Event Behavior can only be attached to access records");
        }

        // make sure the parent definition provided is acceptable
        if ($this->childOf != null && !($this->childOf instanceof ActiveQuery)) {
            $this->childOf = null;
        }

        // if debug is null, use the Yii is_debug variable
        if ($this->debug === null) {
            $this->debug = YII_DEBUG;
        }

        // ensure the provided data is an array - if not, make an array of it
        if ($this->ignoreUpdateAttributes != null && !is_array($this->ignoreUpdateAttributes)) {
            $this->ignoreUpdateAttributes = [$this->ignoreUpdateAttributes];
        }
        if (!is_array($this->authorAttributes)) {
            $this->authorAttributes = [$this->authorAttributes];
        }
        if (!is_array($this->excludeAttributes)) {
            $this->excludeAttributes = [$this->excludeAttributes];
        }
        if (!is_array($this->includeRelations)) {
            $this->includeRelations = [$this->includeRelations];
        }
        if (!is_array($this->includeAttributes)) {
            $this->includeAttributes = [$this->includeAttributes];
        }
        if (!is_array($this->constructedAttributes)) {
            $this->constructedAttributes = [$this->constructedAttributes];
        }

        // if not an associative array, make it one by duplicating the values as keys
        if (is_array($this->includeAttributes) && $this->includeAttributes != null && !ArrayHelper::isAssociative($this->includeAttributes)) {
            $serial = $this->includeAttributes;
            $this->includeAttributes = [];
            foreach ($serial as $value) {
                $this->includeAttributes[$value] = $value;
            }
        }

        // if not an associative array, make it one by duplicating the values as keys
        if (is_array($this->constructedAttributes)) {
            foreach ($this->constructedAttributes as &$construction) {
                if (is_array($construction) && $construction != null && !ArrayHelper::isAssociative($construction)) {
                    $serial = $construction;
                    $construction = [];
                    foreach ($serial as $value) {
                        $construction[$value] = $value;
                    }
                }
            }
        }

        // if not an associative array, make it one by duplicating the values as keys
        if (is_array($this->includeRelations) && $this->includeRelations != null && !ArrayHelper::isAssociative($this->includeRelations)) {
            $serial = $this->includeRelations;
            $this->includeRelations = [];
            foreach ($serial as $value) {
                $this->includeRelations[$value] = $value;
            }
        }

        // if not an associative array, make it one by duplicating the values as keys
        if (is_array($this->authorAttributes) && $this->authorAttributes != null && !ArrayHelper::isAssociative($this->authorAttributes)) {
            $serial = $this->authorAttributes;
            $this->authorAttributes = [];
            foreach ($serial as $value) {
                $this->authorAttributes[$value] = $value;
            }
        }

        // if no specific entity name set, use the table name
        if ($this->entityName == null) {
            $this->entityName = $this->owner->tableName();
        }
        // if no exchange name set, use the entity name
        if ($this->exchangeName == null) {
            $this->exchangeName = 'onespace.config';
        }
        // prep the primary key
        if ($this->primaryKey == null) {
            $this->primaryKey = $this->owner->primaryKey();
        }
        if (!is_array($this->primaryKey)) {
            $this->primaryKey = [$this->primaryKey];
        }

        // if not an associative array, make it one by duplicating the values as keys
        if (is_array($this->primaryKey) && $this->primaryKey != null && !ArrayHelper::isAssociative($this->primaryKey)) {
            $serial = $this->primaryKey;
            $this->primaryKey = [];
            foreach ($serial as $value) {
                $this->primaryKey[$value] = $value;
            }
        }
    }

    /**
     * This allows you to batch sync event payloads and send them in one transaction.
     *
     * ## Important
     *
     * 1. You must be sure your consumer can handle an array of the standard data format.
     * 2. You intend this to hit a single queue on a single x-consistant-hash.
     * 3. All the models passed in as a param must be of the same Class with the rabbit
     *    behavior enabled. Not doing so will cause data to be emitted to an unintended target.
     *
     * Use sparingly, you have been warned
     *
     * @param \yii\base\Model[] $models Array of full models with the rabbit event behavior attached
     *
     * ## Usage
     *
     * ```php
     * $models = MyModel::findAll([param => $param]);
     * RabbitEventBehavior::bulkEmitSyncronisationEvent($models);
     * ```
     */
    public static function bulkEmitSyncronisationEvent(array $models): void {
        $payload = [];

        foreach ($models as $model) {
            try {
                $payload[] = $model->prepareSyncMessage();
            } catch (\Throwable $th) {
                Yii::error($th->getMessage(), __METHOD__);
                continue;
            }
        }

        $lastModel = end($models);

        $exception = null;
        try {
            if ($lastModel->useOutbox) {
                // send the message to a defined exchange
                $lastModel->rabbitComponent->outboxSendMessage(
                    $lastModel->getExchangeName(),
                    Json::encode($payload),
                    $lastModel->getRabbitBehavourRoutingKey('sync'),
                    ['hash-on' => $lastModel->getHashablePrimaryKey()]
                );
            } else {
                $lastModel->rabbitComponent->sendMessage(
                    $lastModel->rabbitComponent->declareExchange($lastModel->getExchangeName(), Rabbit::EXCHANGE_TYPE_TOPIC, [Rabbit::EXCHANGE_FLAG_DURABLE]),
                    Json::encode($payload),
                    $lastModel->getRabbitBehavourRoutingKey('sync'),
                    ['hash-on' => $lastModel->getHashablePrimaryKey()]
                );
            }
        } catch (Exception $ex) {
            $exception = $ex;
        }
        // throw an exception to let the model know something went wrong
        if ($exception !== null) {
            throw new Exception('Event emmission error occurred: ' . $exception->getMessage(), 0, $exception->getCode(), $exception);
        }
    }

    /**
     * Common consistant way of creating the full payload sent on a emitSync
     */
    public function prepareSyncMessage(): array {
        $message = [
            'event' => [
                'type' => 'sync',
                'timestamp' => floor(microtime(true)),
            ],
            'entity' => [
                'type' => $this->entityName,
                'primaryKey' => $this->getPrimaryKey(),
                'attributes' => $this->prepareAttributes(),
                'relations' => $this->prepareRelations(),
            ],
        ];

        if ($this->extraData !== null) {
            $message['entity']['extra'] = $this->prepareExtra();
        }

        return $message;
    }

    /**
     * Fire a manual sync event, which sends the current state of all attributes in the entity
     */
    public function emitSyncronisationEvent() {
        $exception = null;
        try {
            $message = $this->prepareSyncMessage();

            if ($this->useOutbox) {
                // send the message to a defined exchange
                $this->rabbitComponent->outboxSendMessage(
                    $this->getExchangeName(),
                    Json::encode($message),
                    $this->getRabbitBehavourRoutingKey('sync'),
                    ['hash-on' => $this->getHashablePrimaryKey()]
                );
            } else {
                $this->rabbitComponent->sendMessage(
                    $this->rabbitComponent->declareExchange($this->getExchangeName(), Rabbit::EXCHANGE_TYPE_TOPIC, [Rabbit::EXCHANGE_FLAG_DURABLE]),
                    Json::encode($message),
                    $this->getRabbitBehavourRoutingKey('sync'),
                    ['hash-on' => $this->getHashablePrimaryKey()]
                );
            }
        } catch (Exception $ex) {
            $exception = $ex;
        }
        // throw an exception to let the model know something went wrong
        if ($exception !== null) {
            throw new Exception('Event emmission error occurred: ' . $exception->getMessage(), 0, $exception->getCode(), $exception);
        }
    }

    /**
     * Similar to `emitSyncronisationEvent`, except the routing key is user defined. This allows a targeted sync event to a specified service
     */
    public function emitTargettedSyncronisationEvent(string $routingKey): void {
        $exception = null;
        try {
            $message = $this->prepareSyncMessage();

            // send the message to a defined exchange
            $this->rabbitComponent->outboxSendMessage(
                $this->getExchangeName(),
                Json::encode($message),
                $routingKey . implode('.', $this->getPrimaryKey()),
                ['hash-on' => $this->getHashablePrimaryKey()]
            );
        } catch (Exception $ex) {
            $exception = $ex;
        }
        // throw an exception to let the model know something went wrong
        if ($exception !== null) {
            throw new Exception('Event emmission error occurred: ' . $exception->getMessage(), $exception->getCode(), $exception);
        }
    }

    /**
     * Extract whatever is needed to produce the author object
     */
    public function getAuthor() {
        // populate the author info if left empty and not false
        if ($this->author == null && $this->author !== false) {
            if (Yii::$app->get('user', false) != null && Yii::$app->user != null && Yii::$app->user->identityClass != null && Yii::$app->user->identity != null) {
                $this->author = Yii::$app->user->identity;
            } else {
                $this->author = null;
            }
        }
        // make sure there is an author to load data from
        if ($this->author != null) {
            // if there are no specific defined attributes, do the entire class
            if ($this->authorAttributes != null) {
                $author = [];
                foreach ($this->authorAttributes as $attribute => $newAttribute) {
                    $author[$newAttribute] = ArrayHelper::getValue($this->author, $attribute);
                }
                return $author;
            } else {
                return ArrayHelper::toArray($this->author);
            }
        } else {
            return null;
        }
    }

    /**
     * Consistently extract the primary key of the system
     */
    public function getPrimaryKey() {
        // get the primary key values
        $pk = [];
        foreach ($this->primaryKey as $attribute => $alias) {
            $pk[$alias] = ArrayHelper::getValue($this->owner, $attribute);
        }
        return $pk;
    }

    /**
     * Get a consistent primary key that can be used for hashing to ensure events are delivered in order
     */
    public function getHashablePrimaryKey() {
        $pk = $this->getPrimaryKey();
        // sort the array by property name so that different orders don't throw things off
        ksort($pk);
        return implode('.', $pk);
    }

    /**
     * Get a name for the exchange we need to send to
     */
    public function getExchangeName() {
        $prefix = '';
        if ($this->debug) {
            // $prefix = 'dev_';
        }
        return $prefix . $this->exchangeName;
    }

    /**
     * Prepare routing keys in a consistent way
     */
    public function getRabbitBehavourRoutingKey(string $event) {
        return $this->entityName . ".$event." . implode('.', $this->getPrimaryKey());
    }

    /**
     * Prep a transaction to cover the event, so that we can undo it if for some reason the event isn't emitted
     */
    public function prepareTransaction() {
        if ($this->useOutbox) {
            // If using Rabbit Outbox, switch atomic off otherwise changes will never save.
            $this->atomic = false;
        }
        // if this is a standard yii db connection (not an azure one)
        if ($this->atomic && $this->owner->getDb() instanceof Connection) {
            $this->transaction = $this->owner->getDb()->beginTransaction();
        }
        // have to return true to allow things to complete
        return true;
    }

    /**
     * Load up the attributes of the model, including any minor ones we want
     */
    public function prepareAttributes() {
        $return = [];

        $attributes = $this->owner->attributes;
        // allow merging with another model
        if ($this->mergeWith != null) {
            foreach ($this->mergeWith as $relationName => $relation) {
                if ($relation->multiple) {
                    // there are multiple of this model tied to the parent. Load them into an array
                    $attributes[$relationName] = [];
                    foreach ($relation->all() as $relatedModel) {
                        $pk = $this->getPrimaryKey();
                        // sort the array by property name so that different orders don't throw things off
                        ksort($pk);
                        $attributes[$relationName][implode('|', $pk)] = $relatedModel->attributes;
                    }
                } else {
                    // there is only a single relation. Merge them into a single object
                    // if there is no object there, create an empty one so the fields exist
                    if (($relatedModel = $relation->one()) == null) {
                        $relatedModel = new $relation->modelClass();
                    }
                    $attributes = ArrayHelper::merge($attributes, $relatedModel->attributes);
                }
            }
        }

        // exclude any required attributes
        // include any constructed attributes
        foreach ($attributes as $attribute => $value) {
            if ($this->enforceCamelCase && str_contains($attribute, '_')) {
                $outAttribute = Inflector::variablize($attribute);
            } else {
                $outAttribute = $attribute;
            }

            if (!in_array($attribute, $this->excludeAttributes)) {
                $return[$outAttribute] = $value;
            }
            if (key_exists($attribute, $this->constructedAttributes)) {
                $constructed = $this->constructedAttributes[$attribute];
                if (is_array($constructed)) {
                    foreach ($constructed as $constructedAttr => $displayConstructedAttr) {
                        $return[$displayConstructedAttr] = ArrayHelper::getValue($this->owner, $constructedAttr);
                    }
                } else {
                    $return[$constructed] = ArrayHelper::getValue($this->owner, $constructed);
                }
            }
        }
        // include any other attributes explicitly added
        foreach ($this->includeAttributes as $attribute => $alias) {
            $return[$alias] = ArrayHelper::getValue($this->owner, $attribute);
        }
        return $return;
    }

    /**
     * Load any relations that we want to pass
     */
    protected function prepareRelations() {
        $return = [];
        foreach ($this->includeRelations as $relationName => $alias) {
            $relations = ArrayHelper::getValue($this->owner, $relationName);
            // do nothing if there is no record
            if ($relations == null) {
                $return[$alias] = $relations;
                continue;
            }

            $return[$alias] = [];
            if (!is_array($relations) || ArrayHelper::isIndexed($relations)) {
                // there is a single item with attributes
                foreach ($relations as $attribute => $value) {
                    if (!in_array($relationName . '.' . $attribute, $this->excludeAttributes)) {
                        if ($this->enforceCamelCase && str_contains($attribute, '_')) {
                            $outAttribute = Inflector::variablize($attribute);
                        } else {
                            $outAttribute = $attribute;
                        }
                        $return[$alias][$outAttribute] = $value;
                    }
                }
            } else {
                // there is a list of items, run through them one at a time
                foreach ($relations as $relation) {
                    $relationReturn = [];
                    foreach ($relation as $attribute => $value) {
                        if (!in_array($relationName . '.' . $attribute, $this->excludeAttributes)) {
                            if ($this->enforceCamelCase && str_contains($attribute, '_')) {
                                $outAttribute = Inflector::variablize($attribute);
                            } else {
                                $outAttribute = $attribute;
                            }
                            $relationReturn[$outAttribute] = $value;
                        }
                    }
                    $return[$alias][] = $relationReturn;
                }
            }
        }
        return $return;
    }

    /**
     * Get any extra data that should be passed to the queue.
     */
    protected function prepareExtra(): array {
        if ($this->extraData instanceof Closure) {
            $fn = $this->extraData;
            $extra = $fn();
        } else {
            $extra = $this->extraData;
        }
        return $extra;
    }

    /**
     * Emit a creation event to the server
     */
    public function afterInsert($event) {
        $exception = null;
        try {
            // get the current timestamp
            $timestamp = floor(microtime(true));
            $message = [
                'event' => [
                    'type' => 'create',
                    'timestamp' => $timestamp,
                ],
                'entity' => [
                    'type' => $this->entityName,
                    'primaryKey' => $this->getPrimaryKey(),
                    'attributes' => $this->prepareAttributes(),
                    'relations' => $this->prepareRelations(),
                ],
                'author' => $this->getAuthor(),
            ];

            if ($this->extraData !== null) {
                $message['entity']['extra'] = $this->prepareExtra();
            }

            // send the message to a defined exchange
            if ($this->useOutbox) {
                $this->rabbitComponent->outboxSendMessage(
                    $this->getExchangeName(),
                    Json::encode($message),
                    $this->getRabbitBehavourRoutingKey('create'),
                    ['hash-on' => $this->getHashablePrimaryKey()],
                    $timestamp
                );
            } else {
                $this->rabbitComponent->sendMessage(
                    $this->rabbitComponent->declareExchange($this->getExchangeName(), Rabbit::EXCHANGE_TYPE_TOPIC, [Rabbit::EXCHANGE_FLAG_DURABLE]),
                    Json::encode($message),
                    $this->getRabbitBehavourRoutingKey('create'),
                    ['hash-on' => $this->getHashablePrimaryKey()],
                    $timestamp
                );
            }
            // job done
            if ($this->transaction != null) {
                $this->transaction->commit();
            }
        } catch (Exception $ex) {
            if ($this->transaction != null) {
                $this->transaction->rollback();
            }
            $exception = $ex;
        } finally {
            $this->transaction = null;
        }
        // throw an exception to let the model know something went wrong
        if ($exception !== null) {
            throw new DbException('Event emmission error occurred: ' . $exception->getMessage(), [], $exception->getCode(), $exception);
        }
    }

    /**
     * Emit an update event to the rabbit server
     */
    public function afterUpdate(AfterSaveEvent $event) {
        $exception = null;
        try {
            // get the new values for each changed attribute
            $changedAttributes = $event->changedAttributes;

            // get the current values of the attributes
            $newAttributes = [];
            $oldAttributes = [];
            $preppedAttributes = $this->prepareAttributes();
            foreach (array_keys($changedAttributes) as $rawKey) {
                if ($this->enforceCamelCase) {
                    $preppedAttribute = Inflector::variablize($rawKey);
                } else {
                    $preppedAttribute = $rawKey;
                }
                Yii::info("$rawKey - $preppedAttribute", __METHOD__);
                Yii::info(json_encode($preppedAttributes), __METHOD__);
                Yii::info(json_encode($changedAttributes), __METHOD__);
                // run an additional level of filtering - ensure the values have actually changed, and that we aren't excluding them
                if (array_key_exists($preppedAttribute, $preppedAttributes) && ArrayHelper::getValue($preppedAttributes, $preppedAttribute) != ArrayHelper::getValue($changedAttributes, $rawKey)) {
                    // handle attributes with . separators
                    $newAttributes[$preppedAttribute] = $preppedAttributes[$preppedAttribute];
                    $oldAttributes[$preppedAttribute] = $changedAttributes[$rawKey];
                }

                $table = $this->owner::tableName();

                // check if the attribute is used in a constructed one
                if (array_key_exists($rawKey, $this->constructedAttributes)) {
                    Yii::info("Constructed key exists for {$rawKey} on table {$table}...", __METHOD__);
                    $constructed = $this->constructedAttributes[$rawKey];
                    if (is_array($constructed)) {
                        foreach ($constructed as $constructedAttr => $displayConstructedAttr) {
                            $newAttributes[$displayConstructedAttr] = ArrayHelper::getValue($this->owner, $constructedAttr);
                        }
                    } else {
                        $newAttributes[$constructed] = ArrayHelper::getValue($this->owner, $constructed);
                    }
                } else {
                    Yii::info("Constructed key does not exist for {$rawKey} on table {$table}...", __METHOD__);
                    Yii::info("Constructed Attributes: " . json_encode($this->constructedAttributes), __METHOD__);
                    Yii::info("Array Keys of Constructed: " . json_encode(array_keys($this->constructedAttributes)), __METHOD__);
                }
            }

            // check if we should even send a message - if only ignored attributes were changed, no need
            $ignoredOnly = false;
            if ($this->ignoreUpdateAttributes != null) {
                $ignoredOnly = true;
                foreach (array_keys($newAttributes) as $key) {
                    // as soon as we find a single non-ignored attribute, we can bail out of the loop
                    if (!in_array($key, $this->ignoreUpdateAttributes)) {
                        $ignoredOnly = false;
                        break;
                    }
                }
            }

            // if there are no non-ignored attributes, no need to send but we still need to save
            if (!$ignoredOnly) {
                // get the current timestamp
                $timestamp = floor(microtime(true));
                $pk = $this->getPrimaryKey();

                $message = [
                    'event' => [
                        'type' => 'update',
                        'timestamp' => $timestamp,
                    ],
                    'entity' => [
                        'type' => $this->entityName,
                        'primaryKey' => $pk,
                        'oldAttributes' => $oldAttributes,
                        'attributes' => $newAttributes,
                        'relations' => $this->prepareRelations(),
                    ],
                    'author' => $this->getAuthor(),
                ];

                if ($this->sendModelAttributes === true) {
                    $message['entity']['modelAttributes'] = $preppedAttributes;
                }

                if ($this->extraData !== null) {
                    $message['entity']['extra'] = $this->prepareExtra();
                }

                // send the message to a defined exchange
                if ($this->useOutbox) {
                    $this->rabbitComponent->outboxSendMessage(
                        $this->getExchangeName(),
                        Json::encode($message),
                        $this->getRabbitBehavourRoutingKey('update'),
                        ['hash-on' => $this->getHashablePrimaryKey()],
                        $timestamp
                    );
                } else {
                    $this->rabbitComponent->sendMessage(
                        $this->rabbitComponent->declareExchange($this->getExchangeName(), Rabbit::EXCHANGE_TYPE_TOPIC, [Rabbit::EXCHANGE_FLAG_DURABLE]),
                        Json::encode($message),
                        $this->getRabbitBehavourRoutingKey('update'),
                        ['hash-on' => $this->getHashablePrimaryKey()],
                        $timestamp
                    );
                }
            }

            // job done
            if ($this->transaction != null) {
                $this->transaction->commit();
            }
        } catch (Exception $ex) {
            if ($this->transaction != null) {
                $this->transaction->rollback();
            }
            $exception = $ex;
        } finally {
            $this->transaction = null;
        }
        // throw an exception to let the model know something went wrong
        if ($exception !== null) {
            throw new DbException('Event emmission error occurred: ' . $exception->getMessage(), [], $exception->getCode(), $exception);
        }
    }

    /**
     * Emit a deletion
     */
    public function afterDelete($event) {
        $exception = null;
        try {
            // get the current timestamp
            $timestamp = floor(microtime(true));
            // get the primary key values
            $pk = $this->getPrimaryKey();

            $message = [
                'event' => [
                    'type' => 'delete',
                    'timestamp' => $timestamp,
                ],
                'entity' => [
                    'type' => $this->entityName,
                    'primaryKey' => $pk,
                    'attributes' => $this->prepareAttributes(),
                    'relations' => $this->prepareRelations(),
                ],
                'author' => $this->getAuthor(),
            ];

            if ($this->sendModelAttributes === true) {
                $message['entity']['modelAttributes'] = $this->prepareAttributes();
            }

            if ($this->extraData !== null) {
                $message['entity']['extra'] = $this->prepareExtra();
            }

            // send the message to a defined exchange
            if ($this->useOutbox) {
                $this->rabbitComponent->outboxSendMessage(
                    $this->getExchangeName(),
                    Json::encode($message),
                    $this->getRabbitBehavourRoutingKey('delete'),
                    ['hash-on' => $this->getHashablePrimaryKey()],
                    $timestamp
                );
            } else {
                $this->rabbitComponent->sendMessage(
                    $this->rabbitComponent->declareExchange($this->getExchangeName(), Rabbit::EXCHANGE_TYPE_TOPIC, [Rabbit::EXCHANGE_FLAG_DURABLE]),
                    Json::encode($message),
                    $this->getRabbitBehavourRoutingKey('delete'),
                    ['hash-on' => $this->getHashablePrimaryKey()],
                    $timestamp
                );
            }
            // job done
            if ($this->transaction != null) {
                $this->transaction->commit();
            }
        } catch (Exception $ex) {
            if ($this->transaction != null) {
                $this->transaction->rollback();
            }
            $exception = $ex;
        } finally {
            $this->transaction = null;
        }
        // throw an exception to let the model know something went wrong
        if ($exception !== null) {
            throw new DbException('Event emmission error occurred: ' . $exception->getMessage(), [], $exception->getCode(), $exception);
        }
    }

    /**
     * Prep a transaction to cover the event, so that we can undo it if for some reason the event isn't emitted
     */
    public function prepareTransactionAsChild() {

        if (!$this->parentExists()) {
            return false;
        }

        $this->parent = $this->childOf->one();

        foreach ($this->parent?->behaviors as $behavior) {
            if ($behavior instanceof self) {
                // we've found the rabbit behavior
                return $behavior->prepareTransaction();
            }
        }

        return false;
    }

    /**
     * Prep a transaction to cover the event, so that we can undo it if for some reason the event isn't emitted. Deletion as a child is a special case - we need to hold onto the current values of the attributes
     */
    public function prepareTransactionForDeletionAsChild() {
        $this->attributeCache = $this->owner->attributes;

        return $this->prepareTransactionAsChild();
    }

    /**
     * Emit a creation event to our parent model
     */
    public function afterInsertAsChild($event) {

        if ($this->parentExists()) {
            // an insertion counts as an update as far as the parent is concerned
            foreach ($this->parent->behaviors as $behavior) {
                if ($behavior instanceof self) {
                    // we've found the rabbit behavior
                    try {
                        $behavior->afterUpdate(new AfterSaveEvent([
                            // changedAttributes includes the old values of attributes - in this case it's null
                            'changedAttributes' => array_fill_keys(array_keys($this->owner->attributes), null),
                        ]));
                        return;
                    } catch (Exception $ex) {
                        throw new DbException('Event emmission error occurred: ' . $ex->getMessage(), [], $ex->getCode(), $ex);
                    }
                }
            }
        }
    }

    /**
     * Emit an update event to our parent model
     */
    public function afterUpdateAsChild(AfterSaveEvent $event) {

        if (!$this->parentExists()) {
            return;
        }

        if ($this->ignoreUpdateAttributes != null) {
            $changed = array_keys($event->changedAttributes);
            $nonIgnored = array_diff($changed, $this->ignoreUpdateAttributes);
            if (empty($nonIgnored)) {
                return; // only ignored attributes changed, don't notify parent
            }
        }

        foreach ($this->parent->behaviors as $behavior) {
            if ($behavior instanceof self) {
                try {
                    $behavior->afterUpdate(new AfterSaveEvent([
                        'changedAttributes' => $event->changedAttributes,
                    ]));
                    return;
                } catch (Exception $ex) {
                    throw new DbException('Event emmission error occurred: ' . $ex->getMessage(), [], $ex->getCode(), $ex);
                }
            }
        }
    }

    /**
     * Emit a deletion to our parent model
     */
    public function afterDeleteAsChild($event) {

        if ($this->parentExists()) {
            // a deletion of a child would reflect as an update to the parent
            foreach ($this->parent->behaviors as $behavior) {
                if ($behavior instanceof self) {
                    // we've found the rabbit behavior
                    try {
                        $behavior->afterUpdate(new AfterSaveEvent([
                            // changedAttributes includes the old values of attributes - here we have to load them from our cache
                            'changedAttributes' => $this->attributeCache,
                        ]));
                        $this->attributeCache = null;
                        return;
                    } catch (Exception $ex) {
                        throw new DbException('Event emmission error occurred: ' . $ex->getMessage(), [], $ex->getCode(), $ex);
                    }
                }
            }
        }
    }

    /**
     * To be used in `beforeSave` or `beforeDelete` in the case of a validation failure.
     * If you are returning false, clear the transaction if it exists.
     */
    public function clearRabbitTransaction(): void {
        if ($this->transaction !== null && $this->transaction->isActive) {
            $this->transaction->rollBack();
        }
        $this->transaction = null;
    }

    /**
     * Check if parent exists on models configured as childOf another model
     * @return bool
     */
    private function parentExists(): bool {

        $this->parent = $this->childOf->one();

        if ($this->parent === null || $this->parent === false) {
            return false;
        }

        return true;
    }
}
