<?php
/**
 * Mercury Provisioning Module
 * 
 * This module serves to atuomate the process of a customer enacting anything for a Mercury product and the relevant changes in Creatia.
 *
 * @copyright Copyright (c) GSL Networks 2025
 * @author Jesse Cunningham
 */

 // Include some helper scripts we've created.
include_once(__DIR__ . '/PhpCreatia.php');

use WHMCS\Database\Capsule;

if (!defined("WHMCS")) {
    die("This file cannot be accessed directly");
}

/**
 * Define module related meta data.
 *
 * Values returned here are used to determine module related abilities and
 * settings.
 *
 * @see https://developers.whmcs.com/provisioning-modules/meta-data-params/
 *
 * @return array
 */
function mercury_MetaData()
{
    return array(
        'DisplayName' => 'Mercury Provisioning',
        'APIVersion' => '1.0',
        'RequiresServer' => true, // Set true if module requires a server to work
        'DefaultNonSSLPort' => '1111', // Default Non-SSL Connection Port
        'DefaultSSLPort' => '1112', // Default SSL Connection Port
    );
}

/**
 * Define product configuration options.
 *
 * Nothing is required at this stage for Mercury.
 *
 * @see https://developers.whmcs.com/provisioning-modules/config-options/
 *
 * @return array
 */
function mercury_ConfigOptions()
{
    return array();
}

/**
 * Load the module configuration and hand back a ready to use Creatia client.
 *
 * Everything is validated up front so a missing file or key is reported as a
 * readable error. Left unchecked it becomes a TypeError on the constructor,
 * and a TypeError is not an Exception - it would escape the catch blocks below
 * and take the request down without writing anything to the module log.
 *
 * @return PhpCreatia
 * @throws Exception if the configuration is missing or incomplete
 */
function mercury_getApi()
{
    $configuration_path = __DIR__ . '/config.json';

    if (!is_readable($configuration_path)) {
        throw new Exception("Mercury configuration is missing or unreadable: " . $configuration_path);
    }

    $configuration = json_decode(file_get_contents($configuration_path), true);

    if (!is_array($configuration)) {
        throw new Exception("Mercury configuration is not valid JSON: " . $configuration_path);
    }

    foreach (['creatia-endpoint', 'creatia-identity', 'creatia-secret'] as $key) {
        if (empty($configuration[$key])) {
            throw new Exception("Mercury configuration has no value for '" . $key . "'.");
        }
    }

    return new PhpCreatia($configuration['creatia-endpoint'], $configuration['creatia-identity'], $configuration['creatia-secret']);
}

/**
 * Resolve the WHMCS client that identifies this customer's organisation.
 *
 * The 'gsl' external identity is not a free WHMCS reference - it is the CXID
 * that the network side dereferences. The tunnel microservice is called as
 * /api/v1/tunnels/by-cxid/{identity}, the BGP scanner as /scan/cxid/{identity},
 * and broadcast:mercury-rates pulls per-tunnel state and billing rates keyed on
 * it. The Creatia button writes tblclients.id under the same key, so that is the
 * value the whole system agrees on.
 *
 * The committed history of this file used clientsdetails.owner_user_id, a tblusers ID
 * from a separate sequence. That version was never deployed - production has always
 * written the client ID - so this restores parity rather than changing behaviour.
 * Shipping the owner-user version would have orphaned every existing organisation.
 *
 * @param array $params common module parameters
 *
 * @return int the WHMCS client ID
 * @throws Exception if the service has no client attached
 */
function mercury_getOrganisationIdentity(array $params)
{
    $client_id = (int) ($params['userid']
        ?? $params['clientsdetails']['userid']
        ?? $params['clientsdetails']['id']
        ?? 0);

    if ($client_id <= 0) {
        throw new Exception("This service has no client attached and cannot be matched to a Creatia organisation.");
    }

    return $client_id;
}

/**
 * Pull the Creatia organisation ID out of an external identity lookup.
 *
 * @param mixed $external_identities_call response from getExternalIdentities
 *
 * @return string|null the Creatia ID, or null when the organisation is unknown
 * @throws Exception if the response is not in the shape we expect
 */
function mercury_getCreatiaId($external_identities_call)
{
    if (!is_array($external_identities_call) || !isset($external_identities_call['data']) || !is_array($external_identities_call['data'])) {
        throw new Exception("Unexpected response from Creatia looking up an organisation: " . json_encode($external_identities_call));
    }

    if (sizeof($external_identities_call['data']) === 0) {
        return null;
    }

    if (empty($external_identities_call['data'][0]['creatia_id'])) {
        throw new Exception("Creatia returned an organisation with no identifier: " . json_encode($external_identities_call));
    }

    // More than one organisation holding the same identity is ambiguous, and picking a row
    // would mean acting on an arbitrary one of them. update_organisation_v2's 'add' mode
    // performs no uniqueness check, so this state is reachable.
    $organisations = array_unique(array_column($external_identities_call['data'], 'creatia_id'));

    if (sizeof($organisations) > 1) {
        throw new Exception("Identity is held by " . sizeof($organisations) . " organisations (" . implode(', ', $organisations) . ") - resolve by hand.");
    }

    return $external_identities_call['data'][0]['creatia_id'];
}

/**
 * Require the client's billing address to be a verified login address.
 *
 * Called before adding a user to an organisation that already exists, and before linking
 * the organisation an existing login belongs to - never on the resolve path. Either way
 * the address is what ties the client to the organisation, so an unverified one lets a
 * client claim an address they do not control: a user created against it squats the
 * address so its owner can never provision, and a link hands over an organisation that
 * is not theirs.
 *
 * Deliberately NOT applied to Terminate or Change Package. Those act on an organisation
 * that already exists and send nothing; refusing there would leave the product right and
 * the tunnels live after billing has stopped, which is worse than the thing being
 * prevented.
 *
 * Verification lives on the WHMCS user (tblusers.email_verified_at) while provisioning
 * uses the client's address, and the two can diverge, so both have to line up - the same
 * rule the Creatia button applies.
 *
 * @param string $email the client's billing address
 * @param int $client_id
 *
 * @return void
 * @throws Exception if the address cannot be treated as proven
 */
function mercury_requireVerifiedAddress(string $email, int $client_id)
{
    if (trim($email) === '') {
        throw new Exception("Client $client_id has no email address.");
    }

    $owner = Capsule::table('tblusers_clients')
        ->join('tblusers', 'tblusers.id', '=', 'tblusers_clients.auth_user_id')
        ->where('tblusers_clients.client_id', $client_id)
        ->where('tblusers_clients.owner', 1)
        ->orderBy('tblusers.id', 'asc')
        ->first(['tblusers.email', 'tblusers.email_verified_at']);

    if ($owner === null) {
        throw new Exception("Client $client_id has no owner user, so $email cannot be treated as verified.");
    }

    if ($owner->email_verified_at === null) {
        throw new Exception("Client $client_id's login address is not verified, so $email cannot be tied to a Creatia organisation. Have the customer verify it and retry.");
    }

    if (strtolower(trim((string) $owner->email)) !== strtolower(trim($email))) {
        throw new Exception("Client $client_id's billing address ($email) is not their verified login address ({$owner->email}). Bring the two into line and retry.");
    }
}

/**
 * Every WHMCS client ID filed against an organisation under the 'gsl' key.
 *
 * An organisation can hold several: the Creatia button adds one whenever it links a
 * client, and CreateAccount adds one when it links a customer's existing login. Product
 * rights, bandwidth and tunnels all belong to the organisation rather than to one client,
 * so anything deciding "has this customer any Mercury service left" has to consider all
 * of them - scoping to one client would report none while another's service is still
 * live, revoke the right, and take every tunnel on the organisation down with it.
 *
 * @param PhpCreatia $api
 * @param string $creatia_id
 *
 * @return array client IDs - empty for an organisation never filed under 'gsl'
 * @throws Exception if the list cannot be enumerated
 */
function mercury_organisationClientIds($api, string $creatia_id)
{
    $call = $api->getExternalIdentities('gsl');

    if (!isset($call['data']) || !is_array($call['data'])) {
        throw new Exception("Could not enumerate gsl identities to scope organisation $creatia_id: " . json_encode($call));
    }

    $client_ids = [];

    foreach ($call['data'] as $row) {
        if (isset($row['creatia_id'], $row['external_id']) && (string) $row['creatia_id'] === $creatia_id) {
            $client_ids[] = (int) $row['external_id'];
        }
    }

    return $client_ids;
}

/**
 * Locate the customer's Creatia organisation.
 *
 * Organisations are filed under the WHMCS client ID, which is what this module and the
 * Creatia button both write and what the network side reads as a CXID.
 *
 * @param PhpCreatia $api
 * @param array $params common module parameters
 *
 * @return array [ Creatia organisation ID or null, the client identity, every client ID
 *               the organisation holds ]
 * @throws Exception if the service has no client or a lookup fails
 */
function mercury_resolveOrganisation($api, array $params)
{
    $client_id = mercury_getOrganisationIdentity($params);

    $call = $api->getExternalIdentities('gsl', $client_id);

    logModuleCall(
        'mercury',
        __FUNCTION__,
        "Made request to Creatia",
        "getExternalIdentities: " . json_encode($call),
        json_encode($call),
        json_encode($call)
    );

    $creatia_id = mercury_getCreatiaId($call);

    if ($creatia_id === null) {
        return [null, $client_id, [$client_id]];
    }

    $client_ids = mercury_organisationClientIds($api, (string) $creatia_id);

    // Found by this client's identity, so the full listing has to include at least that.
    if ($client_ids === []) {
        throw new Exception("Organisation $creatia_id resolved from an identity but holds none - the identity listing is inconsistent.");
    }

    return [$creatia_id, $client_id, $client_ids];
}

/**
 * Make sure the organisation actually holds this customer's user.
 *
 * A run that created the organisation and then failed at the user leaves one without,
 * and the service would otherwise go live with bandwidth and product rights, no
 * invitation sent, and 'success' returned.
 *
 * Reached both for an organisation created moments ago in this same run and for one that
 * already existed, which is what lets a provision that stopped half way finish on the next
 * attempt instead of leaving a customer with rights and bandwidth but no way to log in.
 *
 * @param PhpCreatia $api
 * @param string $creatia_id
 * @param string $email
 * @param string $name
 * @return void
 * @throws Exception if the user could not be created, or the address belongs elsewhere
 */
function mercury_ensureUser($api, string $creatia_id, string $email, string $name, int $client_id, bool $organisation_is_new)
{
    $user = $api->getUser($email);

    // An empty body decodes to [] in this client, which would read as "no user" and let
    // the caller create one. Absence has to be stated, not inferred.
    if (!array_key_exists('user', $user) || !array_key_exists('organisation_id', $user)) {
        throw new Exception("Creatia did not answer whether $email already has a user: " . json_encode($user));
    }

    $existing_organisation = $user['organisation_id'] ?? null;

    if ($existing_organisation !== null && (string) $existing_organisation === $creatia_id) {
        return;
    }

    // The address belongs to a different organisation, so this one cannot be confirmed
    // to have a user. Whether it was created moments ago or already existed, carrying on
    // would grant bandwidth and rights to an organisation nobody may be able to log into
    // - and a per-run "did I just create it" flag does not help, because the state is
    // durable and the next attempt would sail past it.
    if ($existing_organisation !== null) {
        throw new Exception("$email already belongs to organisation $existing_organisation, so no user could be added to organisation $creatia_id. Resolve by hand.");
    }

    // Only when adding to an organisation that already existed. The first user in an
    // organisation created moments ago is a first-time provision: the invitation goes to
    // the address, so an unproven one gains nothing. Requiring it there would break the
    // ordinary sign-up-and-order flow, where nobody has opened the verification mail yet.
    if (!$organisation_is_new) {
        mercury_requireVerifiedAddress($email, $client_id);
    }

    $call = $api->createUser($creatia_id, $email, $name);

    if (empty($call['detail'])) {
        throw new Exception("Creatia did not return an identifier for the new user: " . json_encode($call));
    }

    logModuleCall(
        'mercury',
        __FUNCTION__,
        "Made request to Creatia",
        "createUser: " . json_encode($call),
        json_encode($call),
        json_encode($call)
    );
}

/**
 * Name of the configurable option that carries a service's Mercury bandwidth.
 *
 * Referenced both when reading module parameters and when reading other
 * services straight out of the database, so it lives in one place.
 *
 * @return string
 */
function mercury_bandwidthOptionName()
{
    return 'Additional Data (Mbit)';
}

/**
 * Read the bandwidth configurable option off a service.
 *
 * The option is created by hand in WHMCS - mercury_ConfigOptions() defines
 * nothing - so renaming or omitting it needs to produce a message an admin can
 * act on rather than a type error deep inside the API client.
 *
 * @param array $params common module parameters
 *
 * @return int|float the configured bandwidth
 * @throws Exception if the option is absent or not a number
 */
function mercury_getBandwidth(array $params)
{
    $mbit = $params['configoptions'][mercury_bandwidthOptionName()] ?? null;

    if (!is_numeric($mbit)) {
        throw new Exception("The '" . mercury_bandwidthOptionName() . "' configurable option is missing or not a number for this product. Check the product's configurable options in WHMCS.");
    }

    return $mbit + 0;
}

/**
 * Provision a new instance of a product/service. Creates an organisation if it doesn't exist, adds a user, adds product rights and sets Mercury bandwidth.
 */
function mercury_CreateAccount(array $params)
{
    try {
        // Preamble
        $api = mercury_getApi();

        [$creatia_id, $organisation_id, $organisation_client_ids] = mercury_resolveOrganisation($api, $params);
        $organisation_name = $params['clientsdetails']['companyname'] != '' ? $params['clientsdetails']['companyname'] : $params['clientsdetails']['fullname'];
        $owner_email = $params['clientsdetails']['email'];
        $owner_name = $params['clientsdetails']['fullname'];
        // The organisation holds one bandwidth value between every service the
        // customer has, so this is the largest of them rather than this one's.
        $sibling_services = mercury_getSiblingServices($params, $organisation_client_ids);
        $mbit = mercury_getOrganisationBandwidth($params, $sibling_services);


        $organisation_is_new = false;

        if($creatia_id === null) {

            // Decided before anything is written. An address that already has a Creatia
            // login is linked rather than given a second organisation, and one create_user
            // will reject must not leave an empty organisation holding this client's CXID.
            $existing = $api->getUser($owner_email);

            if (!array_key_exists('organisation_id', $existing)) {
                throw new Exception("Creatia did not answer whether $owner_email already has a user: " . json_encode($existing));
            }

            if ($existing['organisation_id'] !== null) {

                // The customer already has a login, in an organisation that holds no identity
                // for this client. Link that organisation to the client and provision it, but
                // only from a verified address - that shows the client controls the login
                // rather than merely naming it.
                mercury_requireVerifiedAddress($owner_email, (int) $organisation_id);

                $creatia_id = (string) $existing['organisation_id'];

                // The organisation may already run Mercury under another client, so the tier
                // is rebuilt across every client it will hold - this client alone would let a
                // small order drag it down.
                $organisation_client_ids = mercury_organisationClientIds($api, $creatia_id);
                $organisation_client_ids[] = (int) $organisation_id;
                $sibling_services = mercury_getSiblingServices($params, $organisation_client_ids);
                $mbit = mercury_getOrganisationBandwidth($params, $sibling_services);

                $link_organisation_call = $api->addExternalIdentity($creatia_id, 'gsl', (string) $organisation_id);

                logModuleCall(
                    'mercury',
                    __FUNCTION__,
                    "Made request to Creatia",
                    "addExternalIdentity: " . json_encode($link_organisation_call),
                    json_encode($link_organisation_call),
                    json_encode($link_organisation_call)
                );

                // The module log only records while debug logging is on, and this decides who
                // owns what, so it goes on the client's activity log as well.
                logActivity("Mercury: linked existing Creatia organisation $creatia_id to this client, since their verified address $owner_email already has a login there.", (int) $organisation_id);
            }
            else {

                // The DNS check mirrors the API's own 'email:rfc,dns' rule, which is otherwise
                // only discovered after the organisation exists.
                $domain = substr(strrchr($owner_email, '@') ?: '', 1);

                if ($domain === '' || !checkdnsrr($domain, 'MX') && !checkdnsrr($domain, 'A')) {
                    throw new Exception("$owner_email is not an address Creatia will accept (no MX or A record for '$domain'), so no organisation was created for client $organisation_id.");
                }

                // If not setup, create organisation, create user, setup product right.
                $create_organisation_call = $api->createOrganisation($organisation_name, [[ 'key' => 'gsl', 'identity' => (string) $organisation_id ]], 'gsl');

                if (empty($create_organisation_call['detail'])) {
                    throw new Exception("Creatia did not return an identifier for the new organisation: " . json_encode($create_organisation_call));
                }

                $creatia_id = $create_organisation_call['detail'];
                $organisation_is_new = true;

                logModuleCall(
                    'mercury',
                    __FUNCTION__,
                    "Made request to Creatia",
                    "createOrganisation: " . json_encode($create_organisation_call),
                    json_encode($create_organisation_call),
                    json_encode($create_organisation_call)
                );
            }
        }

        // Runs whether the organisation was just created or already existed - see
        // mercury_ensureUser() for why the latter matters.
        mercury_ensureUser($api, (string) $creatia_id, $owner_email, $owner_name, (int) $organisation_id, $organisation_is_new);

        $update_mercury_call = $api->updateMercury($creatia_id, $mbit);
        $add_product_call = $api->addProductRight($creatia_id, 'mercury');

        logModuleCall(
            'mercury',
            __FUNCTION__,
            "Made request to Creatia",
            "updateMercury: " . json_encode($update_mercury_call),
            json_encode($update_mercury_call),
            json_encode($update_mercury_call)
        );

        logModuleCall(
            'mercury',
            __FUNCTION__,
            "Made request to Creatia",
            "addProductRight: " . json_encode($add_product_call),
            json_encode($add_product_call),
            json_encode($add_product_call)
        );

        return 'success';

    } catch (Throwable $e) {
        // Record the error in WHMCS's module log.
        logModuleCall(
            'mercury',
            __FUNCTION__,
            $params,
            $e->getMessage(),
            $e->getTraceAsString()
        );

        return $e->getMessage();
    }
}

/**
 * Suspend an instance of a product/service. Exists just to make WHMCS happy
 */
function mercury_SuspendAccount(array $params)
{
    try {

        logModuleCall(
            'mercury',
            __FUNCTION__,
            "Suspension call for service - No action executed on Creatia.",
            "User information: " . json_encode([
                'client-id'     => $params['userid'] ?? null,
                'email'         => $params['clientsdetails']['email'],
                'bandwidth'     => $params['configoptions']['Additional Data (Mbit)'] ?? null
            ]),
            '[]',
            '[]'
        );

        return 'success';

    } catch (Throwable $e) {
        // Record the error in WHMCS's module log.
        logModuleCall(
            'mercury',
            __FUNCTION__,
            $params,
            $e->getMessage(),
            $e->getTraceAsString()
        );

        return $e->getMessage();
    }
}

/**
 * Un-suspend instance of a product/service. Exists just to satisfy WHMCS.
 */
function mercury_UnsuspendAccount(array $params)
{
    try {

        logModuleCall(
            'mercury',
            __FUNCTION__,
            "Unsuspension call for service - No action executed on Creatia.",
            "User information: " . json_encode([
                'client-id'     => $params['userid'] ?? null,
                'email'         => $params['clientsdetails']['email'],
                'bandwidth'     => $params['configoptions']['Additional Data (Mbit)'] ?? null
            ]),
            '[]',
            '[]'
        );

        return 'success';

    } catch (Throwable $e) {
        // Record the error in WHMCS's module log.
        logModuleCall(
            'mercury',
            __FUNCTION__,
            $params,
            $e->getMessage(),
            $e->getTraceAsString()
        );

        return $e->getMessage();
    }
}

/**
 * Find any other Mercury services that belong to the same Creatia organisation.
 *
 * Product rights in Creatia are held by the organisation, not by the individual
 * WHMCS service, so the right only comes away once every service has gone.
 *
 * Scoped to every client the organisation is filed against, not just the acting one.
 * Rights and bandwidth belong to the organisation, so a service under any of its clients
 * keeps them - see mercury_organisationClientIds().
 *
 * @param array $params common module parameters
 * @param array $client_ids every client ID the organisation holds
 *
 * @return array service IDs of the organisation's remaining Mercury services
 */
function mercury_getSiblingServices(array $params, array $client_ids)
{
    // Suspended counts as live on purpose - mercury_SuspendAccount deliberately
    // leaves Creatia untouched, so a suspended service still holds its rights.
    $live_statuses = ['Active', 'Suspended'];

    $service_id = (int) ($params['serviceid'] ?? ($params['model']->id ?? 0));

    $client_ids = array_values(array_unique(array_filter(array_map('intval', $client_ids))));

    if ($client_ids === []) {
        return [];
    }

    return Capsule::table('tblhosting')
        ->join('tblproducts', 'tblproducts.id', '=', 'tblhosting.packageid')
        ->where('tblproducts.servertype', 'mercury')
        ->whereIn('tblhosting.userid', $client_ids)
        ->whereIn('tblhosting.domainstatus', $live_statuses)
        ->where('tblhosting.id', '!=', $service_id)
        ->pluck('tblhosting.id')
        ->all();
}

/**
 * Read the bandwidth configurable option for a set of services.
 *
 * WHMCS keeps the answer in one of two places depending on how the option was
 * defined - quantity and yes/no options hold it as a quantity on the service,
 * dropdowns and radios hold it in the name of the chosen sub option - so both
 * are read here. Sub option names are stored as "value|description", and the
 * option itself may be named "name|description", hence the splitting.
 *
 * @param array $service_ids WHMCS service IDs to look up
 *
 * @return array bandwidth keyed by service ID, omitting anything unreadable
 */
function mercury_getServiceBandwidths(array $service_ids)
{
    if (sizeof($service_ids) === 0) {
        return [];
    }

    $option_name = mercury_bandwidthOptionName();

    $rows = Capsule::table('tblhostingconfigoptions')
        ->join('tblproductconfigoptions', 'tblproductconfigoptions.id', '=', 'tblhostingconfigoptions.configid')
        ->leftJoin('tblproductconfigoptionssub', 'tblproductconfigoptionssub.id', '=', 'tblhostingconfigoptions.optionid')
        ->whereIn('tblhostingconfigoptions.relid', $service_ids)
        ->where(function ($query) use ($option_name) {
            $query->where('tblproductconfigoptions.optionname', $option_name)
                ->orWhere('tblproductconfigoptions.optionname', 'like', $option_name . '|%');
        })
        ->get([
            'tblhostingconfigoptions.relid',
            'tblhostingconfigoptions.qty',
            'tblproductconfigoptions.optiontype',
            'tblproductconfigoptionssub.optionname'
        ]);

    $bandwidths = [];

    foreach ($rows as $row) {
        $row = (array) $row;

        // Types 3 and 4 are yes/no and quantity, which carry their value as the
        // quantity. Everything else carries it in the selected sub option.
        if (in_array((int) $row['optiontype'], [3, 4], true)) {
            $value = $row['qty'];
        }
        else {
            $value = explode('|', (string) $row['optionname'])[0];
        }

        if (is_numeric($value)) {
            $bandwidths[(int) $row['relid']] = $value + 0;
        }
    }

    return $bandwidths;
}

/**
 * Work out the bandwidth an organisation should be holding.
 *
 * Mercury bandwidth is one value against the organisation rather than against
 * the service, so it has to be rebuilt from every live service the customer
 * holds. Overwriting it with whichever service was touched last is what let a
 * duplicate or smaller order drag an existing customer down to that plan's
 * allowance.
 *
 * The plans are tiers rather than allowances that stack, so the highest one
 * wins. Customers routinely trial on a small plan before moving up and the two
 * run alongside each other until the old one is cancelled - through that
 * overlap the organisation belongs on the larger of the two, not their total.
 *
 * @param array $params common module parameters
 * @param array $sibling_services the customer's other live Mercury services
 * @param bool $include_current_service false while terminating, since the
 *                                      service being removed no longer counts
 *
 * @return int|float the bandwidth of the customer's largest live service
 * @throws Exception if any live service's bandwidth cannot be read, rather than
 *                   quietly settling on a tier that is too small
 */
function mercury_getOrganisationBandwidth(array $params, array $sibling_services, bool $include_current_service = true)
{
    $bandwidths = mercury_getServiceBandwidths($sibling_services);
    $unreadable = array_diff($sibling_services, array_keys($bandwidths));

    // Only fatal while the service is being kept: settling on a tier that is too small is
    // worse than failing. On the way out the figure no longer gates anything, and refusing
    // would leave the right and the tunnels live after billing has stopped.
    if (sizeof($unreadable) !== 0) {

        if ($include_current_service) {
            throw new Exception("Cannot work out the organisation's bandwidth - no readable '" . mercury_bandwidthOptionName() . "' option on service(s) " . implode(', ', $unreadable) . ".");
        }

        logModuleCall(
            'mercury',
            __FUNCTION__,
            "Ignoring unreadable sibling services while tearing down.",
            "No readable '" . mercury_bandwidthOptionName() . "' option on service(s) " . implode(', ', $unreadable) . ".",
            json_encode($unreadable),
            '[]'
        );
    }

    $bandwidths = array_values($bandwidths);

    if ($include_current_service) {
        $bandwidths[] = mercury_getBandwidth($params);
    }

    if (sizeof($bandwidths) === 0) {
        return 0;
    }

    return max($bandwidths);
}

/**
 * Terminate instance of a product/service.
 *
 * The Mercury right is only revoked once the customer has no other live Mercury
 * service left - upgrades, downgrades and duplicate orders all leave one behind.
 * Where one does remain, the organisation keeps its right and falls back to the
 * largest of those remaining services.
 */
function mercury_TerminateAccount(array $params)
{
    try {

        // Preamble
        $api = mercury_getApi();

        [$creatia_id, , $organisation_client_ids] = mercury_resolveOrganisation($api, $params);

        // Rights belong to the organisation, so they only come away once every
        // service has gone. Anything else and the customer keeps working, minus
        // the bandwidth this service was contributing.
        $sibling_services = mercury_getSiblingServices($params, $organisation_client_ids);

        if (sizeof($sibling_services) !== 0) {

            // Only rebalance when every surviving service could be read. Unreadable ones
            // come back as no bandwidth at all, and writing that would zero an
            // organisation whose other services are still live and billed.
            $readable = sizeof(mercury_getServiceBandwidths($sibling_services)) === sizeof($sibling_services);

            if ($creatia_id !== null && $readable) {
                $update_mercury_call = $api->updateMercury($creatia_id, mercury_getOrganisationBandwidth($params, $sibling_services, false));

                logModuleCall(
                    'mercury',
                    __FUNCTION__,
                    "Made request to Creatia",
                    "updateMercury: " . json_encode($update_mercury_call),
                    json_encode($update_mercury_call),
                    json_encode($update_mercury_call)
                );
            }
            elseif ($creatia_id !== null) {
                logModuleCall(
                    'mercury',
                    __FUNCTION__,
                    "Left bandwidth untouched - a surviving service has an unreadable bandwidth option.",
                    "Remaining service IDs: " . json_encode($sibling_services),
                    json_encode($sibling_services),
                    '[]'
                );
            }

            logModuleCall(
                'mercury',
                __FUNCTION__,
                "Retained Mercury product right - client has other live services.",
                "Remaining service IDs: " . json_encode($sibling_services),
                json_encode($sibling_services),
                json_encode($sibling_services)
            );

            return 'success';
        }

        if($creatia_id !== null) {
            // Make a call to revoke a users right
            $revoke_product_call = $api->removeProductRight($creatia_id, 'mercury');

            logModuleCall(
                'mercury',
                __FUNCTION__,
                "Made request to Creatia",
                "removeProductRight: " . json_encode($revoke_product_call),
                json_encode($revoke_product_call),
                json_encode($revoke_product_call)
            );
        }

        return 'success';

    } catch (Throwable $e) {
        // Record the error in WHMCS's module log.
        logModuleCall(
            'mercury',
            __FUNCTION__,
            $params,
            $e->getMessage(),
            $e->getTraceAsString()
        );

        return $e->getMessage();
    }
}


/**
 * Upgrade or downgrade an instance of a product/service.
 *
 * @param array $params common module parameters
 *
 * @see https://developers.whmcs.com/provisioning-modules/module-parameters/
 *
 * @return string "success" or an error message
 */
function mercury_ChangePackage(array $params)
{
    try {

        // Preamble
        $api = mercury_getApi();

        [$creatia_id, $organisation_id, $organisation_client_ids] = mercury_resolveOrganisation($api, $params);

        $sibling_services = mercury_getSiblingServices($params, $organisation_client_ids);
        $mbit = mercury_getOrganisationBandwidth($params, $sibling_services);

        if($creatia_id !== null) {
            $update_mercury_call = $api->updateMercury($creatia_id, $mbit);

            logModuleCall(
                'mercury',
                __FUNCTION__,
                "Made request to Creatia",
                "updateMercury: " . json_encode($update_mercury_call),
                json_encode($update_mercury_call),
                json_encode($update_mercury_call)
            );
        }
        else {
            logModuleCall(
                'mercury',
                __FUNCTION__,
                "No Creatia organisation for this client - nothing to update.",
                "Client ID: " . $organisation_id,
                '[]',
                '[]'
            );
        }

        return 'success';

    } catch (Throwable $e) {
        // Record the error in WHMCS's module log.
        logModuleCall(
            'mercury',
            __FUNCTION__,
            $params,
            $e->getMessage(),
            $e->getTraceAsString()
        );

        return $e->getMessage();
    }
}