<?php
/**
 * Customizable merchant-facing texts.
 *
 * Every string the plugin renders to a subscriber lives here. The registry is
 * the single source of truth: the admin form, the defaults and the values
 * handed to JavaScript are all derived from it, so adding a customizable
 * string means adding one entry below and nothing else on the PHP side.
 *
 * Resolution order for any key is: merchant override -> translation -> English
 * default. An override saved as an empty string is treated as "not set" so a
 * blank field falls back to the default instead of rendering an empty button.
 */

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

define( 'CHURNSOLUTION_TEXTS_OPTION', 'churnsolution_texts' );

/**
 * The customizable strings, grouped the way they are shown in the admin form.
 *
 * Only strings a subscriber can actually see today are listed. The error
 * messages inside the currently disabled coupon/pause/extend handlers are
 * deliberately left out so the form does not offer to customize text that
 * never renders.
 *
 * @return array<string, array<string, mixed>> keyed by group slug.
 */
function churnsolution_get_text_registry() {
    return array(
        'buttons' => array(
            'label'       => __( 'Account page buttons', 'churnsolution' ),
            // Only rendered by replaceCancellationButton(), which runs behind an
            // isWooCommeerce check. Paid Memberships Pro keeps its own cancel link.
            'integration' => 'woocommerce',
            'help'        => __( 'The buttons the plugin adds to the subscription on your customer account page.', 'churnsolution' ),
            'fields'      => array(
                'button_skip' => array(
                    'label'   => __( 'Skip button', 'churnsolution' ),
                    'default' => __( 'Skip this %s', 'churnsolution' ),
                    'help'    => __( '%s is replaced with the billing period (for example "month"). Remove it to always show the same wording.', 'churnsolution' ),
                ),
                'button_pause' => array(
                    'label'   => __( 'Pause button', 'churnsolution' ),
                    'default' => __( 'Pause Subscription', 'churnsolution' ),
                ),
                'button_resume' => array(
                    'label'   => __( 'Resume button', 'churnsolution' ),
                    'default' => __( 'Resume Subscription Now', 'churnsolution' ),
                ),
                'button_cancel' => array(
                    'label'   => __( 'Cancel button', 'churnsolution' ),
                    'default' => __( 'Cancel Subscription', 'churnsolution' ),
                ),
                'button_manage' => array(
                    'label'   => __( 'Manage button', 'churnsolution' ),
                    'default' => __( 'Manage Subscription', 'churnsolution' ),
                    'help'    => __( 'Shown instead of the individual buttons when more than one action is available.', 'churnsolution' ),
                ),
            ),
        ),
        // Labels *inside* the cancellation flow modal (Back, Next, Decline
        // Offer, Done, Close, ...) are deliberately absent. The SDK resolves
        // them as `chrTranslations.CF_MODAL_X || chrGlobalText.X`, and every
        // CF_MODAL_* key ships with a non-empty default, so the right-hand side
        // is unreachable. That means the SDK's `customTexts` option cannot
        // change them, and nothing the plugin sends will ever take effect.
        // Those labels are configured in the Churn Solution app, under
        // Settings > Configuration > Multilingual and Text Overrides.
        // Do not add them here.
        'invoice' => array(
            'label'       => __( 'Subscription details', 'churnsolution' ),
            // handleUpcomingInvoice() only touches PMPro account markup.
            'integration' => 'pmpro',
            'help'        => __( 'Labels added to the upcoming invoice on the account page.', 'churnsolution' ),
            'fields'      => array(
                'invoice_after_discount' => array(
                    'label'   => __( 'After discount', 'churnsolution' ),
                    'default' => __( 'after discount', 'churnsolution' ),
                ),
                'invoice_after_taxes' => array(
                    'label'   => __( 'After taxes', 'churnsolution' ),
                    'default' => __( 'after taxes', 'churnsolution' ),
                ),
                'invoice_paused_until' => array(
                    'label'   => __( 'Paused until', 'churnsolution' ),
                    'default' => __( 'Paused until', 'churnsolution' ),
                ),
            ),
        ),
        'errors' => array(
            'label'       => __( 'Error messages', 'churnsolution' ),
            // handleCancel only calls handleSubscriptionCancel() on the non-Woo
            // branch, so this message can only surface on Paid Memberships Pro.
            'integration' => 'pmpro',
            'help'        => __( 'Shown to the customer when an action could not be completed.', 'churnsolution' ),
            'fields'      => array(
                'error_cancel_failed' => array(
                    'label'   => __( 'Cancellation failed', 'churnsolution' ),
                    'default' => __( 'Failed to cancel. Please contact support.', 'churnsolution' ),
                ),
            ),
        ),
    );
}

/**
 * Human-readable name for an integration slug.
 *
 * @param string $integration 'woocommerce' | 'pmpro' | 'both'
 * @return string
 */
function churnsolution_integration_label( $integration ) {
    switch ( $integration ) {
        case 'woocommerce':
            return __( 'WooCommerce', 'churnsolution' );
        case 'pmpro':
            return __( 'Paid Memberships Pro', 'churnsolution' );
        default:
            return __( 'All integrations', 'churnsolution' );
    }
}

/**
 * Which integrations can actually render these texts on this site.
 *
 * Detection is by runtime class/function/constant rather than
 * is_plugin_active(): a path check breaks when the plugin folder is renamed,
 * needs wp-admin/includes/plugin.php loaded, and cannot see functionality that
 * ships bundled inside another plugin. Runtime symbols have none of those
 * problems and also work unchanged on multisite, where a plugin may be network
 * activated. Must be called after `plugins_loaded`.
 *
 * Returns every match rather than the first one: a store can run WooCommerce
 * and Paid Memberships Pro side by side, and then both sets of texts are live.
 *
 * @return string[] Any of 'woocommerce', 'pmpro'; empty when neither is present.
 */
function churnsolution_active_integrations() {
    $active = array();

    // The WooCommerce texts label the subscription actions on the account
    // page. Those come from WooCommerce Subscriptions, NOT core WooCommerce:
    // core has its own account "cancel" action, but it cancels an *order* and
    // its link carries no subscription_id, so churn-solution-custom.js filters
    // it out. Checking for core WooCommerce would therefore show these fields
    // on stores that can never display them.
    // wcs_get_subscription() is checked too because the same account markup
    // ships in subscriptions-core, which is bundled inside WooPayments and
    // renders the buttons without the standalone Subscriptions plugin.
    if ( class_exists( 'WC_Subscriptions' ) || function_exists( 'wcs_get_subscription' ) ) {
        $active[] = 'woocommerce';
    }

    // PMPRO_VERSION is defined at the top of the main plugin file; the function
    // check is a fallback for forks that skip the constant.
    if ( defined( 'PMPRO_VERSION' ) || function_exists( 'pmpro_getLevel' ) ) {
        $active[] = 'pmpro';
    }

    /**
     * Filters the detected integrations.
     *
     * Lets a site force a set when detection cannot see a custom setup.
     *
     * @param string[] $active Any of 'woocommerce', 'pmpro'.
     */
    return apply_filters( 'churnsolution_active_integrations', $active );
}

/**
 * The registry filtered to what this site can actually use.
 *
 * A WooCommerce store never renders the Paid Memberships Pro labels and vice
 * versa, so showing both sets invites merchants to edit wording that will
 * never appear. When neither integration is detected the full registry is
 * returned: an empty settings screen would be worse than an unfiltered one,
 * and the plugin may simply be configured before its integration is enabled.
 *
 * @return array<string, array<string, mixed>>
 */
function churnsolution_get_visible_text_registry() {
    $registry = churnsolution_get_text_registry();
    $active   = churnsolution_active_integrations();

    if ( empty( $active ) ) {
        return $registry;
    }

    $visible = array();

    foreach ( $registry as $key => $group ) {
        $integration = isset( $group['integration'] ) ? $group['integration'] : 'both';

        if ( 'both' === $integration || in_array( $integration, $active, true ) ) {
            $visible[ $key ] = $group;
        }
    }

    return $visible;
}

/**
 * The text keys in a registry slice, flattened.
 *
 * @param array $registry Full or filtered registry.
 * @return string[]
 */
function churnsolution_registry_keys( $registry ) {
    $keys = array();

    foreach ( $registry as $group ) {
        foreach ( array_keys( $group['fields'] ) as $key ) {
            $keys[] = $key;
        }
    }

    return $keys;
}

/**
 * Flattens the registry to key => default.
 *
 * @return array<string, string>
 */
function churnsolution_get_text_defaults() {
    $defaults = array();

    foreach ( churnsolution_get_text_registry() as $group ) {
        foreach ( $group['fields'] as $key => $field ) {
            $defaults[ $key ] = $field['default'];
        }
    }

    return $defaults;
}

/**
 * The merchant's saved overrides, with unknown and blank keys dropped.
 *
 * @return array<string, string>
 */
function churnsolution_get_text_overrides() {
    $saved = get_option( CHURNSOLUTION_TEXTS_OPTION, array() );

    if ( ! is_array( $saved ) ) {
        return array();
    }

    $overrides = array();
    $defaults  = churnsolution_get_text_defaults();

    foreach ( $saved as $key => $value ) {
        if ( ! isset( $defaults[ $key ] ) || ! is_string( $value ) ) {
            continue;
        }

        $value = trim( $value );

        if ( '' === $value ) {
            continue;
        }

        $overrides[ $key ] = $value;
    }

    return $overrides;
}

/**
 * The resolved texts handed to JavaScript.
 *
 * @return array<string, string>
 */
function churnsolution_get_texts() {
    return array_merge( churnsolution_get_text_defaults(), churnsolution_get_text_overrides() );
}

/**
 * Sanitizes and stores submitted overrides.
 *
 * Values are stored as plain text: the front end renders them as text nodes,
 * and stripping markup here keeps that guarantee even if a future caller is
 * less careful. Values equal to their default are not stored, so a merchant
 * who edits a string and changes it back stops overriding it.
 *
 * Only $editable_keys are reconciled, and the save starts from the stored
 * overrides rather than from an empty array. The form shows just the active
 * integration's texts, so a WooCommerce store's saved wording must survive a
 * save made while only the Paid Memberships Pro section is on screen -- and
 * vice versa. Rebuilding the option from the submission alone would silently
 * delete the hidden integration's overrides.
 *
 * @param array      $submitted    Raw $_POST slice.
 * @param string[]|null $editable_keys Keys the form rendered; null means all.
 * @return bool Whether the option changed.
 */
function churnsolution_save_texts( $submitted, $editable_keys = null ) {
    $defaults  = churnsolution_get_text_defaults();
    $overrides = churnsolution_get_text_overrides();

    if ( null === $editable_keys ) {
        $editable_keys = array_keys( $defaults );
    }

    foreach ( $editable_keys as $key ) {
        if ( ! isset( $defaults[ $key ] ) ) {
            continue;
        }

        $value = isset( $submitted[ $key ] ) && is_string( $submitted[ $key ] )
            ? trim( sanitize_text_field( wp_unslash( $submitted[ $key ] ) ) )
            : '';

        if ( '' === $value || $value === $defaults[ $key ] ) {
            unset( $overrides[ $key ] );
        } else {
            $overrides[ $key ] = $value;
        }
    }

    return update_option( CHURNSOLUTION_TEXTS_OPTION, $overrides );
}

/**
 * Drops overrides, returning those texts to their default wording.
 *
 * Scoped to $editable_keys for the same reason as the save path: resetting the
 * section on screen must not clear an integration the merchant cannot see.
 *
 * @param string[]|null $editable_keys Keys to reset; null means all of them.
 */
function churnsolution_reset_texts( $editable_keys = null ) {
    if ( null === $editable_keys ) {
        return update_option( CHURNSOLUTION_TEXTS_OPTION, array() );
    }

    $overrides = churnsolution_get_text_overrides();

    foreach ( $editable_keys as $key ) {
        unset( $overrides[ $key ] );
    }

    return update_option( CHURNSOLUTION_TEXTS_OPTION, $overrides );
}
