<?php
declare(strict_types=1);

namespace App\Service\Dolibarr;

use App\Entity\Core\AddressPostal;
use App\Entity\Core\ContactPoint;
use App\Entity\Organization\Organization;
use App\Entity\Person\Person;
use App\Enum\Access\FunctionEnum;
use App\Enum\Access\RoleEnum;
use App\Enum\Core\ContactPointTypeEnum;
use App\Enum\Network\NetworkEnum;
use App\Enum\Organization\AddressPostalOrganizationTypeEnum;
use App\Enum\Organization\OrganizationIdsEnum;
use App\Enum\Organization\SettingsProductEnum;
use App\Enum\Person\GenderEnum;
use App\Repository\Access\AccessRepository;
use App\Repository\Access\FunctionTypeRepository;
use App\Repository\Organization\OrganizationRepository;
use App\Service\Core\AddressPostalUtils;
use App\Service\Rest\Operation\BaseRestOperation;
use App\Service\Rest\Operation\CreateOperation;
use App\Service\Rest\Operation\UpdateOperation;
use App\Service\Utils\ArrayUtils;
use Exception;
use libphonenumber\PhoneNumber;
use libphonenumber\PhoneNumberFormat;
use libphonenumber\PhoneNumberUtil;
use Psr\Log\LoggerInterface;
use RuntimeException;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;
use Symfony\Contracts\Service\Attribute\Required;
use Symfony\Contracts\Translation\TranslatorInterface;

/**
 * Push the data from the Opentalent DB into the Dolibarr DB, trough both applications
 * REST APIs.
 *
 * ** /!\ This sync is and must remain one-sided: Opentalent DB => Dolibarr DB **
 */
class DolibarrSyncService
{
    private LoggerInterface $logger;

    public function __construct(
        private OrganizationRepository $organizationRepository,
        private AccessRepository $accessRepository,
        private FunctionTypeRepository $functionTypeRepository,
        private DolibarrApiService $dolibarrApiService,
        private AddressPostalUtils $addressPostalUtils,
        private ArrayUtils $arrayUtils,
        private TranslatorInterface $translator,
    ) {}

    #[Required]
    /** @see https://symfony.com/doc/current/logging/channels_handlers.html#how-to-autowire-logger-channels */
    public function setLoggerInterface(LoggerInterface $cronLogger): void { $this->logger = $cronLogger; }

    /**
     * Performs a scan, comparing data from the Opentalent DB and the data returned
     * by the Dolibarr API
     *
     * Errors during the scan are recorded in the $this->scanErrors
     *
     * Returns an array of DolibarrSyncOperations
     *
     * @param callable | null $progressionCallback A callback method for indicating the current progression of the process;
     *                                           Shall accept two integer arguments: current progression, and total.
     * @return array<BaseRestOperation>
     * @throws Exception
     *
     * @noinspection NullPointerExceptionInspection
     */
    public function scan(?callable $progressionCallback = null): array
    {
        $this->logger->info("-- Scan started --");

        // Index the dolibarr clients by organization ids
        $dolibarrClientsIndex = $this->getDolibarrSocietiesIndex();
        $this->logger->info(count($dolibarrClientsIndex) . " clients fetched from dolibarr");

        // Get all active accesses
        $membersIndex = $this->getActiveMembersIndex();

        // Get all the missions with an admin default role
        $adminMissions = [];
        foreach ($this->functionTypeRepository->findBy(['roleByDefault' => RoleEnum::ROLE_ADMIN()->getValue()]) as $functionType) {
            $adminMissions[] = $functionType->getMission();
        }

        // Store networks ids id dolibarr
        $cmfDolibarrId = (int)($this->dolibarrApiService->getSociety(OrganizationIdsEnum::CMF()->getValue())['id']);
        $ffecDolibarrId = (int)($this->dolibarrApiService->getSociety(OrganizationIdsEnum::FFEC()->getValue())['id']);

        // Loop over the Opentalent organizations, and fill up the operations list
        $operations = [];
        $i = 0; $total = count($dolibarrClientsIndex);
        foreach ($dolibarrClientsIndex as $organizationId => $dolibarrSociety) {
            $dolibarrSociety = $this->sanitizeDolibarrData($dolibarrSociety);

            $organization = $this->organizationRepository->find($organizationId);
            if ($organization === null) {
                $this->logger->error("Organization " . $organizationId . " not found in the Opentalent DB");
                $i++;
                if ($progressionCallback !== null) {
                    $progressionCallback($i, $total);
                }
                continue;
            }

            // Populate the expected contacts array
            $organizationMembers = $membersIndex[$organization->getId()] ?? [];

            // ===== Update Society =====
            $newSocietyData = [];

            // Sync name
            $newSocietyData['name'] = trim($organization->getName());

            // Sync contact data of the client
            $mainAddress = $this->getOrganizationPostalAddress($organization);
            if ($mainAddress !== null) {
                $streetAddress = $this->addressPostalUtils->getFullStreetAddress($mainAddress);
                if (trim($mainAddress->getAddressOwner() ?? '') !== '') {
                    $streetAddress = $mainAddress->getAddressOwner() . "\n" . $streetAddress;
                }
                $newSocietyData['address'] = $streetAddress;
                $newSocietyData['zip'] = $mainAddress->getPostalCode();
                $newSocietyData['town'] = $mainAddress->getAddressCity();
            } else {
                $newSocietyData['address'] = null;
                $newSocietyData['zip'] = null;
                $newSocietyData['town'] = null;
            }

            // Sync contact
            $newSocietyData['email'] = $this->getOrganizationEmail($organization);
            $newSocietyData['phone'] = $this->getOrganizationPhone($organization);

            // Sync Network
            if (!in_array($organization->getId(), [NetworkEnum::CMF()->getValue(), NetworkEnum::FFEC()->getValue()], true)) {
                $newSocietyData['parent'] = '' . match (
                    $this->getOrganizationNetworkId($organization)
                    ) {
                        NetworkEnum::CMF()->getValue() => $cmfDolibarrId,
                        NetworkEnum::FFEC()->getValue() => $ffecDolibarrId,
                        default => null
                    };
            }

            // More infos
            $infos = [];
            $product = $organization->getSettings()->getProduct();
            if (SettingsProductEnum::isSchool($product)) {
                $infos[] = $this->translator->trans('STUDENTS_COUNT') . " : " .
                    $this->countWithMission([FunctionEnum::STUDENT()->getValue()], $organizationMembers);
            }
            if (SettingsProductEnum::isSchool($product) || SettingsProductEnum::isArtist($product)) {
                $infos[] = $this->translator->trans('ADHERENTS_COUNT') . " : " .
                    $this->countWithMission([FunctionEnum::ADHERENT()->getValue()], $organizationMembers);
            }
            $infos[] = $this->translator->trans('ADMIN_ACCESS_COUNT') . " : " .
                $this->countWithMission($adminMissions, $organizationMembers);

            // /!\ On est forcé de passer la sub-array entière pour mettre à jour le champ modifié, sinon
            //     tous les autres champs seront passés à null...
            $newSocietyData['array_options'] = $dolibarrSociety["array_options"];
            $newSocietyData['array_options']['options_2iopeninfoopentalent'] = implode("\n", $infos);
            if (!empty($product)) {
                $newSocietyData['array_options']['options_2iopen_software_opentalent'] = $this->translator->trans($product);
            }

            // Set the society as active (warning: use the field 'status' for societies, and not 'statut'!)
            $newSocietyData['status'] = '1';

            // Only update the fields that are different (it's important to let it non-recursive, the subarray have to be passed entirely)
            $newSocietyData = $this->arrayUtils->getChanges(
                $dolibarrSociety,
                $newSocietyData,
                false,
                static function ($v1, $v2) { return ($v1 ?? '') === ($v2 ?? ''); }
            );

            // Add an update operation if some data has to be updated
            if (!empty($newSocietyData)) {
                $operations[] = new UpdateOperation(
                    'Update society : ' . $organization->getName() . ' (' . $organization->getId() . ')',
                    'thirdparties',
                    (int)$dolibarrSociety['id'],
                    $newSocietyData,
                    $dolibarrSociety
                );
            }

            // ===== Update Contacts =====
            $dolibarrSocietyContacts = $this->dolibarrApiService->getContacts((int)$dolibarrSociety['id']);
            $contactsProcessed = [];

            foreach ($organizationMembers as $accessId => $missions) {
                // Check if member has office missions, skip if it doesn't
                if (empty(array_intersect($missions, FunctionEnum::getOfficeMissions()))) {
                    continue;
                }

                $access = $this->accessRepository->find($accessId);
                $person = $access?->getPerson();

                // Keep track of the contacts seen
                $contactsProcessed[] = $person->getId();

                // special: if the contact hasn't a firstname and a lastname, ignore it
                if (empty($person->getName()) || empty($person->getGivenName())) {
                    $this->logger->error("Person " . $person->getId() . " miss a lastname and/or a firstname, ignored.");
                    continue;
                }

                // Get the matching dolibarr contact
                $dolibarrContact = $this->findDolibarrContactFor($dolibarrSocietyContacts, $person);
                $dolibarrContact = $this->sanitizeDolibarrData($dolibarrContact);

                $contact = $this->getPersonContact($person);

                // Build parameters for the query (we'll see later if a query is needed)
                $newContactData = [
                    'civility_code' => $person->getGender() ? $this->translator->trans($person->getGender()) : null,
                    'lastname' => trim($person->getName()),
                    'firstname' => trim($person->getGivenName()),
                    'email' => $contact?->getEmail(),
                    'phone_pro' => $contact?->getTelphone() ? $this->formatPhoneNumber($contact->getTelphone()) : null,
                    'phone_mobile' => $contact?->getMobilPhone() ? $this->formatPhoneNumber($contact->getMobilPhone()): null,
                    'poste' => $this->formatContactPosition($missions, $person->getGender()),
                    'statut' => '1'
                ];

                // The person's id may be missing if the contact is new or if it was found through its name
                if ($dolibarrContact !== null && !(empty($dolibarrContact["array_options"] ?? []))) {
                    $newContactData["array_options"] = $dolibarrContact["array_options"];
                } else {
                    $newContactData["array_options"] = [];
                }
                $newContactData["array_options"]["options_2iopen_person_id"] = (string)$person->getId();

                if ($dolibarrContact === null) {
                    // New contact
                    $newContactData['socid'] = (int)$dolibarrSociety['id'];

                    $operations[] = new CreateOperation(
                        'New contact: ' . $person->getName() . ' ' . $person->getGivenName() . ' (' . $person->getId() . ')',
                        'contacts',
                        $newContactData
                    );
                } else {
                    // Only update the fields that are different (it's important to let it non-recursive, the subarray have to be passed entirely)
                    $newContactData = $this->arrayUtils->getChanges(
                        $dolibarrContact,
                        $newContactData,
                        false,
                        static function ($v1, $v2) { return ($v1 ?? '') === ($v2 ?? ''); }
                    );

                    // add an update operation if some data has to be updated
                    if (!empty($newContactData)) {
                        $operations[] = new UpdateOperation(
                            'Update contact: ' . $person->getName() . ' ' . $person->getGivenName() . ' (' . $person->getId() . ')' .
                            ' in ' . $organization->getName() . ' (' . $organization->getId() . ')',
                            'contacts',
                            (int)$dolibarrContact['id'],
                            $newContactData,
                            $dolibarrContact
                        );
                    }
                }
            }

            foreach ($dolibarrSocietyContacts as $contactData) {
                if (empty($contactData["array_options"]["options_2iopen_person_id"])) {
                    continue;
                }
                $personId = (int)$contactData["array_options"]["options_2iopen_person_id"];
                if ((int)$contactData['statut'] === 0) {
                    // contact is already disabled
                    continue;
                }
                if (!in_array($personId, $contactsProcessed, true)) {
                    // Ce personId n'existe plus dans les membres Opentalent de cette société, on delete
                    $operations[] = new UpdateOperation(
                        'Disable contact: ' . $contactData['lastname'] . ' ' . $contactData['firstname'] . ' (' . $personId . ')' .
                        ' from  ' . $organization->getName() . ' (' . $organization->getId() . ')',
                        'contacts',
                        (int)$contactData['id'],
                        ['statut' => '0'],
                        $contactData
                    );
                }
            }

            // Next society
            $i++;
            if ($progressionCallback !== null) {
                $progressionCallback($i, $total);
            }
        }

        $this->logger->info('Scan done, ' . count($operations) . ' required operations listed');
        return $operations;
    }

    /**
     * Execute the operations listed with the DolibarrSyncService::scan method
     *
     * Returns an array of DolibarrSyncOperations
     *
     * @param array<BaseRestOperation> $operations
     * @param callable | null $progressionCallback A callback method for indicating the current progression of the process;
     *                                           Shall accept two integer arguments: current progression, and total.
     * @return array<BaseRestOperation>
     * @throws Exception
     */
    public function execute(array $operations, ?callable $progressionCallback = null): array
    {
        $this->logger->info('-- Execution started --');
        $this->logger->info(count($operations) . ' operations pending...');
        $done = 0; $errors = 0; $unknown = 0;

        $i = 0; $total = count($operations);
        foreach ($operations as $operation) {
            if ($operation->getStatus() !== $operation::STATUS_READY) {
                // operation has already been treated
                $this->logger->warning('Tried to execute an operation that was not marked as ready : ' . $operation);
                $i++;
                if ($progressionCallback !== null) {
                    $progressionCallback($i, $total);
                }
                continue;
            }

            $this->logger->debug($operation->getLabel());
            foreach ($operation->getChangeLog() as $message) {
                $this->logger->debug('   ' . $message);
            }

            try {
                // Execute the request
                $response = $operation->execute($this->dolibarrApiService);

                // Check the status
                if ($operation->getStatus() !== $operation::STATUS_DONE) {
                    $unknown++;
                    throw new RuntimeException('Operation has an inconsistent status : ' . $operation->getStatus());
                }

                // If this is an update operation, validate the result
                if ($operation instanceof UpdateOperation) {
                    try {
                        $this->validateResponse($response, $operation);
                    } catch (RuntimeException $e) {
                        $this->logger->warning($e->getMessage());
                    }
                }

                $done++;
            } catch (RuntimeException $e) {
                $this->logger->error('Error while executing operation : ' . $operation);
                $this->logger->error(implode("\n", $operation->getChangeLog()));
                $this->logger->error($e->getMessage());
                $errors++;
            }

            $i++;
            if ($progressionCallback !== null) {
                $progressionCallback($i, $total);
            }
        }

        $this->logger->info('Execution ended');
        $this->logger->info('Done : ' . $done);
        $this->logger->info('Errors : ' . $errors);
        if ($unknown > 0) {
            $this->logger->warning('Unknown : ' . $unknown);
        }

        return $operations;
    }

    /**
     * Scan and execute the sync process
     *
     * @return array<BaseRestOperation>
     * @throws HttpException
     * @throws Exception
     */
    public function run(): array
    {
        $operations = $this->scan();

        $this->execute($operations);

        return $operations;
    }

    /**
     * Get the client societies dolibarr and index them by organization id
     *
     * @return array<mixed> An index of the form [$organizationId => $dolibarrData]
     */
    protected function getDolibarrSocietiesIndex(): array
    {
        $index = [];
        foreach ($this->dolibarrApiService->getAllClients() as $clientData) {
            $organizationId = $clientData["array_options"]["options_2iopen_organization_id"] ?? null;
            if (!($organizationId > 0)) {

                // Ignoring clients without contract
                $contract = $this->dolibarrApiService->getActiveContract((int)$clientData['id']);
                if (empty($contract)) {
                    continue;
                }

                $this->logger->warning(
                    'Dolibarr client has no organization id: ' .
                    $clientData['name'] . ' (' . $clientData['id'] . ')'
                );
                continue;
            }

            $index[$organizationId] = $clientData;
        }
        return $index;
    }

    /**
     * Returns an index of all the active members with their current mission(s)
     *
     * Index is the form: [$organizationId => [$accessId => [$mission, $mission...], $accessId...], $organizationId2...]
     *
     * @return array<mixed>
     */
    protected function getActiveMembersIndex(): array
    {
        $index = [];
        $results = $this->accessRepository->getAllActiveMembersAndMissions();
        foreach ($results as $row) {
            $accessId = $row['id'];
            $organizationId = $row['organization_id'];
            $mission = $row['mission'];

            if (!array_key_exists($organizationId, $index)) {
                $index[$organizationId] = [];
            }
            if (!array_key_exists($accessId, $index[$organizationId])) {
                $index[$organizationId][$accessId] = [];
            }
            $index[$organizationId][$accessId][] = $mission;
        }
        return $index;
    }

    /**
     * Get the first contact that has the same person id.
     *
     * If none are found with the person id, try to find one with the same full name and no person id
     *
     * @param array<mixed> $dolibarrContacts
     * @param Person $person
     * @return array<mixed>|null
     */
    protected function findDolibarrContactFor(array $dolibarrContacts, Person $person): ?array
    {
        foreach ($dolibarrContacts as $contactData) {
            if (!empty($contactData["array_options"]["options_2iopen_person_id"])) {
                $id = (int)$contactData["array_options"]["options_2iopen_person_id"];

                if ($id === $person->getId()) {
                    return $contactData;
                }
            }
        }

        foreach ($dolibarrContacts as $contactData) {
            if (
                !($contactData["array_options"]["options_2iopen_person_id"] ?? null) &&
                $person->getName() !== null &&
                $person->getGivenName() !== null &&
                strtolower($person->getName()) === strtolower($contactData["lastname"] ?? '') &&
                strtolower($person->getGivenName()) === strtolower($contactData["firstname"] ?? '')
            ) {
                return $contactData;
            }
        }
        return null;
    }

    /**
     * Because for some fields the dolibarr api returns empty strings even when field is null in DB,
     * we have to post-process it to avoid unnecessary and endless update operations
     *
     * As far as we know, there is no harm here to replace every empty string value by a null value
     * (no loss of information)
     *
     * @param array<mixed>|null $data
     * @return array<mixed>|null
     */
    protected function sanitizeDolibarrData(?array $data): ?array
    {
        if ($data === null) {
            return null;
        }

        foreach ($data as $field => $value) {
            if (is_array($value)) {
                $data[$field] = $this->sanitizeDolibarrData($value);
            } else if ($value === '') {
                $data[$field] = null;
            }
        }
        return $data;
    }

    /**
     * Retrieve the postal address of the organization
     *
     * @param Organization $organization
     * @return AddressPostal|null
     */
    protected function getOrganizationPostalAddress(Organization $organization): ?AddressPostal
    {
        $addressPriorities = [
            AddressPostalOrganizationTypeEnum::ADDRESS_BILL()->getValue(),
            AddressPostalOrganizationTypeEnum::ADDRESS_CONTACT()->getValue(),
            AddressPostalOrganizationTypeEnum::ADDRESS_HEAD_OFFICE()->getValue(),
            AddressPostalOrganizationTypeEnum::ADDRESS_PRACTICE()->getValue(),
            AddressPostalOrganizationTypeEnum::ADDRESS_OTHER()->getValue()
        ];

        $organizationAddressPostal = $organization->getOrganizationAddressPostals();

        foreach ($addressPriorities as $addressType) {
            foreach ($organizationAddressPostal as $postalAddress) {
                if ($postalAddress->getType() === $addressType) {
                    return $postalAddress->getAddressPostal();
                }
            }
        }
        return null;
    }

    /**
     * Retrieve the phone for the organization
     *
     * @param Organization $organization
     * @return string|null
     */
    protected function getOrganizationPhone(Organization $organization): ?string
    {
        $contactPriorities = [
            ContactPointTypeEnum::BILL()->getValue(),
            ContactPointTypeEnum::CONTACT()->getValue(),
            ContactPointTypeEnum::PRINCIPAL()->getValue(),
            ContactPointTypeEnum::OTHER()->getValue()
        ];

        $contactPoints = $organization->getContactPoints();

        foreach ($contactPriorities as $contactType) {
            foreach ($contactPoints as $contactPoint) {
                if ($contactPoint->getContactType() === $contactType) {
                    if ($contactPoint->getTelphone() !== null) {
                        return $this->formatPhoneNumber($contactPoint->getTelphone());
                    }
                    if ($contactPoint->getMobilPhone() !== null) {
                        return $this->formatPhoneNumber($contactPoint->getMobilPhone());
                    }
                }
            }
        }
        return null;
    }

    /**
     * Retrieve the email for the organization
     *
     * @param Organization $organization
     * @return string|null
     */
    protected function getOrganizationEmail(Organization $organization): ?string
    {
        $contactPriorities = [
            ContactPointTypeEnum::BILL()->getValue(),
            ContactPointTypeEnum::CONTACT()->getValue(),
            ContactPointTypeEnum::PRINCIPAL()->getValue(),
            ContactPointTypeEnum::OTHER()->getValue()
        ];

        $contactPoints = $organization->getContactPoints();

        foreach ($contactPriorities as $contactType) {
            foreach ($contactPoints as $contactPoint) {
                if ($contactPoint->getContactType() === $contactType && $contactPoint->getEmail() !== null) {
                    return $contactPoint->getEmail();
                }
            }
        }
        return null;
    }

    /**
     * Return the id of the first active network found for the given organization
     *
     * @param Organization $organization
     * @return int|null
     */
    protected function getOrganizationNetworkId(Organization $organization): ?int
    {
        foreach ($organization->getNetworkOrganizations() as $networkOrganization) {
            if ($networkOrganization->getEndDate() !== null && $networkOrganization->getEndDate() < new \DateTime()) {
                continue;
            }
            return $networkOrganization->getNetwork()?->getId();
        }
        return null;
    }

    /**
     * Returns the number of accesses possessing at least one of the missions
     *
     * @param array<mixed> $missions A list of missions
     * @param array<mixed> $members An organization members as returned by getActiveMembersIndex: [$accessID => [$missions...]]
     * @return int
     */
    protected function countWithMission(array $missions, array $members): int
    {
        return count(array_filter(
            $members,
            static function ($actualMissions) use ($missions) { return !empty(array_intersect($actualMissions, $missions)); }
        ));
    }

    /**
     * Return the best contact point for the given Person, or null if none
     *
     * @param Person $person
     * @return ContactPoint|null
     */
    protected function getPersonContact(Person $person): ?ContactPoint
    {
        $contactPriorities = [
            ContactPointTypeEnum::PRINCIPAL()->getValue(),
            ContactPointTypeEnum::OTHER()->getValue()
        ];

        $contactPoints = $person->getContactPoints();

        foreach ($contactPriorities as $contactType) {
            foreach ($contactPoints as $contactPoint) {
                if ($contactPoint->getContactType() === $contactType) {
                    return $contactPoint;
                }
            }
        }
        return null;
    }

    /**
     * Format the contact position from its gender and missions
     *
     * @param list<string> $missions
     * @param string|null $gender
     * @return string
     */
    protected function formatContactPosition(array $missions, ?string $gender = 'X'): string
    {
        $to_exclude = [
            FunctionEnum::ADHERENT()->getValue(),
            FunctionEnum::STUDENT()->getValue(),
            FunctionEnum::OTHER()->getValue()
        ];

        $poste = implode(
            ', ',
            array_map(
                function($m) use ($gender) {
                    return $this->translator->trans(
                        $m,
                        ['gender' => [
                                GenderEnum::MISS()->getValue() => 'F',
                                GenderEnum::MISTER()->getValue() => 'M'
                            ][$gender] ?? 'X']
                    );
                },
                array_filter(
                    $missions,
                    static function ($m) use ($to_exclude) {
                        return !in_array($m, $to_exclude, true);
                    }
                )
            )
        );

        if (strlen($poste) > 80) {
            $poste = mb_substr($poste, 0, 77, "utf-8") . '...';
        }
        return $poste;
    }

    /**
     * Format a phone number into international format
     *
     * @param PhoneNumber $phoneNumber
     * @return mixed
     */
    protected function formatPhoneNumber(PhoneNumber $phoneNumber): mixed
    {
        $phoneUtil = PhoneNumberUtil::getInstance();
        return str_replace(
            ' ',
            '',
            $phoneUtil->format($phoneNumber, PhoneNumberFormat::INTERNATIONAL)
        );
    }


    /**
     * Post-validation of the execution of the operation.
     * Compare the actual result to the expected one to ensure that the data was correctly updated.
     *
     * In the case of a validation error, throw an HttpException
     *
     * @param ResponseInterface $response
     * @param UpdateOperation | CreateOperation  $operation
     * @throws RuntimeException
     */
    protected function validateResponse(ResponseInterface $response, UpdateOperation | CreateOperation $operation): void
    {
        $updated = $operation->getData();

        try {
            $responseData = $response->toArray();
        } catch (ClientExceptionInterface | DecodingExceptionInterface | RedirectionExceptionInterface | ServerExceptionInterface | TransportExceptionInterface $e) {
            throw new RuntimeException(
                "Couldn't read the content of the response : " . $e
            );
        }

        // Sanitize to get rid of the null / empty strings transformations of the API
        $updated = $this->sanitizeDolibarrData($updated);
        $responseData = $this->sanitizeDolibarrData($responseData);

        $diffs = $this->arrayUtils->getChanges($responseData, $updated, true);

        if (!empty($diffs)) {
            /** @noinspection JsonEncodingApiUsageInspection */
            throw new RuntimeException(
                "The " . $operation->getMethod() . " request had an unexpected result.\n" .
                "Expected content: " . json_encode($updated) . "\n" .
                "Actual content  : " . json_encode($responseData)
            );
        }
    }
}
