<?php
/**
 * Keeps the Churn Solution webhooks delivering.
 *
 * WooCommerce disables a webhook on its own once deliveries keep failing: WC_Webhook::failed_delivery()
 * counts consecutive non-2xx responses and disables past woocommerce_max_webhook_delivery_failures
 * (5 by default). Nothing tells the merchant or us — the store just goes quiet, and subscriptions,
 * orders and coupons stop syncing while everything still looks connected.
 *
 * Recovering from inside the store is worth more than recovering over the REST API, because only
 * here can the failure counter be cleared. A webhook reactivated over REST keeps its counter at the
 * ceiling and is disabled again by the very next failure; one reactivated here starts from zero.
 */

if (!defined('ABSPATH')) {
    exit;
}

/**
 * How many times a single webhook may be recovered before we leave it alone.
 *
 * Recovering assumes the failures were transient. When they are not — our endpoint is down, or the
 * store cannot reach it at all — recovering just feeds the webhook back into the same wall and
 * hammers the store with deliveries that cannot succeed. Past this many attempts the webhook is
 * left disabled so the cause gets looked at instead.
 */
define('CHURNSOLUTION_MAX_WEBHOOK_RECOVERIES', 3);

/**
 * How long recovery attempts are remembered. Rolls forward on each attempt, so the limit means
 * "this many recoveries without a quiet day", not "this many ever".
 */
define('CHURNSOLUTION_WEBHOOK_RECOVERY_WINDOW', DAY_IN_SECONDS);

/**
 * Option holding the last thing that happened to each of our webhooks, for support to read.
 */
define('CHURNSOLUTION_WEBHOOK_HEALTH_OPTION', 'churnsolution_webhook_health');

/**
 * Webhooks queued for recovery at the end of this request.
 *
 * @var int[]
 */
global $churnsolution_webhooks_to_recover;
$churnsolution_webhooks_to_recover = array();

add_action('woocommerce_webhook_disabled_due_delivery_failures', 'churnsolution_queue_webhook_recovery', 10, 1);
/**
 * WooCommerce fires this from inside failed_delivery(), which then saves the webhook it already
 * flagged as disabled. Recovering right here would be undone by that save a moment later, so the
 * work is deferred to shutdown and only the id is taken now.
 *
 * @param int $webhook_id
 */
function churnsolution_queue_webhook_recovery($webhook_id)
{
    if (!churnsolution_is_our_webhook($webhook_id)) {
        return;
    }

    global $churnsolution_webhooks_to_recover;

    $churnsolution_webhooks_to_recover[] = (int)$webhook_id;
}

add_action('shutdown', 'churnsolution_recover_queued_webhooks');
/**
 * Runs after WooCommerce has finished writing the disabled webhook, so what we save here sticks.
 */
function churnsolution_recover_queued_webhooks()
{
    global $churnsolution_webhooks_to_recover;

    if (empty($churnsolution_webhooks_to_recover)) {
        return;
    }

    $webhook_ids = array_unique($churnsolution_webhooks_to_recover);
    $churnsolution_webhooks_to_recover = array();

    foreach ($webhook_ids as $webhook_id) {
        churnsolution_recover_webhook($webhook_id);
    }
}

/**
 * Puts one webhook back into delivery, unless it has already been recovered too often.
 *
 * @param int $webhook_id
 */
function churnsolution_recover_webhook($webhook_id)
{
    $webhook = churnsolution_get_webhook($webhook_id);

    if (empty($webhook)) {
        return;
    }

    $attempts = churnsolution_webhook_recovery_attempts($webhook_id);

    if ($attempts >= CHURNSOLUTION_MAX_WEBHOOK_RECOVERIES) {
        churnsolution_record_webhook_health($webhook_id, $webhook, 'left_disabled', $attempts);

        return;
    }

    churnsolution_count_webhook_recovery($webhook_id, $attempts);

    // Clearing the counter is the part that matters and the part the REST API cannot do. Without
    // it the webhook is active but one failure away from being disabled all over again.
    $webhook->set_failure_count(0);
    $webhook->set_status('active');
    $webhook->save();

    churnsolution_record_webhook_health($webhook_id, $webhook, 'recovered', $attempts + 1);
}

/**
 * Whether a webhook belongs to this store's Churn Solution connection.
 *
 * Matching on the app id in the delivery url rather than just the host matters: a store can hold
 * webhooks for more than one of our connections (a live and a test one, say), and a looser check
 * would let this plugin reactivate webhooks pointing at a connection it knows nothing about.
 *
 * @param int $webhook_id
 *
 * @return bool
 */
function churnsolution_is_our_webhook($webhook_id)
{
    $webhook = churnsolution_get_webhook($webhook_id);

    if (empty($webhook)) {
        return false;
    }

    $app_id = churnsolution_get_connection_app_id();

    if (empty($app_id)) {
        return false;
    }

    return strpos((string)$webhook->get_delivery_url(), $app_id) !== false;
}

/**
 * @param int $webhook_id
 *
 * @return WC_Webhook|null
 */
function churnsolution_get_webhook($webhook_id)
{
    if (!function_exists('wc_get_webhook')) {
        return null;
    }

    $webhook = wc_get_webhook($webhook_id);

    return empty($webhook) ? null : $webhook;
}

/**
 * The app id of the connection stored by this plugin, or an empty string when the store is not
 * connected.
 *
 * @return string
 */
function churnsolution_get_connection_app_id()
{
    if (!class_exists('Churnsolution_Connection')) {
        return '';
    }

    $connection = Churnsolution_Connection::get_connection();

    if (empty($connection)) {
        return '';
    }

    return (string)$connection->getAppId();
}

/**
 * @param int $webhook_id
 *
 * @return int
 */
function churnsolution_webhook_recovery_attempts($webhook_id)
{
    return (int)get_transient(churnsolution_webhook_recovery_key($webhook_id));
}

/**
 * @param int $webhook_id
 * @param int $attempts
 */
function churnsolution_count_webhook_recovery($webhook_id, $attempts)
{
    set_transient(
        churnsolution_webhook_recovery_key($webhook_id),
        $attempts + 1,
        CHURNSOLUTION_WEBHOOK_RECOVERY_WINDOW
    );
}

/**
 * @param int $webhook_id
 *
 * @return string
 */
function churnsolution_webhook_recovery_key($webhook_id)
{
    return 'churnsolution_webhook_recovery_' . (int)$webhook_id;
}

/**
 * Leaves a trail of what happened to each webhook, so a store that stopped syncing can be explained
 * without reproducing the failure.
 *
 * @param int        $webhook_id
 * @param WC_Webhook $webhook
 * @param string     $outcome
 * @param int        $attempts
 */
function churnsolution_record_webhook_health($webhook_id, $webhook, $outcome, $attempts)
{
    $health = get_option(CHURNSOLUTION_WEBHOOK_HEALTH_OPTION, array());

    if (!is_array($health)) {
        $health = array();
    }

    $health[(int)$webhook_id] = array(
        'topic'      => $webhook->get_topic(),
        'outcome'    => $outcome,
        'attempts'   => (int)$attempts,
        'at'         => gmdate('c'),
        'plugin'     => CHURNSOLUTION_VERSION,
    );

    // Not autoloaded: this is read when someone goes looking, never on a normal page load.
    update_option(CHURNSOLUTION_WEBHOOK_HEALTH_OPTION, $health, false);
}
