<?php

/**
 * Subscription Notes Helper Functions
 *
 * Functions for managing subscription notes.
 *
 * @package ArraySubs
 */

defined('ABSPATH') || exit;

/**
 * Check if audit logging is enabled for a given entity type.
 *
 * Only the togglable entities (product, coupon, email, settings) are gated.
 * All other entities are always logged.
 *
 * @param string $entity Entity type.
 * @return bool
 */
function arraysubs_is_entity_logging_enabled(string $entity): bool
{
    $togglable = ['product', 'coupon', 'email', 'settings'];

    if (! in_array($entity, $togglable, true)) {
        return true;
    }

    if (! function_exists('arraysubs_get_setting')) {
        return true;
    }

    return (bool) arraysubs_get_setting("audit_logging.{$entity}", true);
}

/**
 * Add a note to a subscription
 *
 * Content is automatically sanitized with wp_kses_post() and make_clickable()
 * to allow safe HTML and auto-link URLs.
 *
 * @param int    $subscription_id The subscription ID.
 * @param string $content         Note content (HTML allowed).
 * @param string $type            Note type: 'private' or 'customer'. Default 'private'.
 * @param int    $added_by        User ID who added the note. 0 for system. Default 0.
 * @param array  $audit_changes   Structured audit change entries.
 * @param string $entity          Entity type for audit classification.
 * @param string $event_type      Structured event type for timeline/reporting classification.
 * @return int|WP_Error Note ID on success, WP_Error on failure.
 */
function arraysubs_add_subscription_note(
    int $subscription_id,
    string $content,
    string $type = 'private',
    int $added_by = 0,
    array $audit_changes = [],
    string $entity = '',
    string $event_type = ''
): int|\WP_Error {
    if ($entity !== '' && ! arraysubs_is_entity_logging_enabled($entity)) {
        return 0;
    }

    // Validate subscription exists
    $subscription = get_post($subscription_id);
    if (!$subscription || $subscription->post_type !== 'arraysubs_data') {
        return new \WP_Error(
            'invalid_subscription',
            __('Invalid subscription ID.', 'arraysubs')
        );
    }

    // Validate note type
    if (!in_array($type, ['private', 'customer'], true)) {
        $type = 'private';
    }

    // Sanitize content - allow safe HTML, then make URLs clickable
    $content = wp_kses_post($content);
    $content = make_clickable($content);

    // Determine if this is a system note
    $is_system = ($added_by === 0);

    // Create the note post
    $note_id = wp_insert_post([
        'post_type'    => 'arraysubs_sub_note',
        'post_status'  => 'publish',
        'post_content' => $content,
        'post_author'  => $added_by > 0 ? $added_by : get_current_user_id(),
        'post_parent'  => 0,
    ], true);

    if (is_wp_error($note_id)) {
        return $note_id;
    }

    // Set meta fields
    update_post_meta($note_id, '_subscription_id', $subscription_id);
    update_post_meta($note_id, '_note_type', $type);
    update_post_meta($note_id, '_added_by', $added_by);
    update_post_meta($note_id, '_is_system', $is_system);
    arraysubs_store_note_audit_changes($note_id, $audit_changes);

    $entity = sanitize_key($entity);
    if ($entity !== '') {
        update_post_meta($note_id, '_audit_entity', $entity);
    }

    $event_type = sanitize_key($event_type);
    if ($event_type !== '') {
        update_post_meta($note_id, '_event_type', $event_type);
    }

    /**
     * Fires after a subscription note is added.
     *
     * @param int    $note_id         The note ID.
     * @param int    $subscription_id The subscription ID.
     * @param string $content         The note content.
     * @param string $type            The note type.
     * @param int    $added_by        User ID who added the note.
     */
    do_action('arraysubs_note_added', $note_id, $subscription_id, $content, $type, $added_by);

    return $note_id;
}

/**
 * Get notes for a subscription
 *
 * @param int   $subscription_id The subscription ID.
 * @param array $args            Optional. Query arguments.
 *                               - type: 'all', 'private', or 'customer'. Default 'all'.
 *                               - per_page: Number of notes to return. Default -1 (all).
 *                               - page: Page number for pagination. Default 1.
 *                               - orderby: Field to order by. Default 'date'.
 *                               - order: 'ASC' or 'DESC'. Default 'DESC'.
 * @return WP_Post[] Array of note posts.
 */
function arraysubs_get_subscription_notes(
    int $subscription_id,
    array $args = []
): array {
    $defaults = [
        'type'     => 'all',
        'per_page' => -1,
        'page'     => 1,
        'orderby'  => 'date',
        'order'    => 'DESC',
    ];

    $args = wp_parse_args($args, $defaults);

    $query_args = [
        'post_type'      => 'arraysubs_sub_note',
        'post_status'    => 'publish',
        'posts_per_page' => $args['per_page'],
        'paged'          => $args['page'],
        'orderby'        => $args['orderby'],
        'order'          => $args['order'],
        'meta_query'     => [
            [
                'key'     => '_subscription_id',
                'value'   => $subscription_id,
                'compare' => '=',
                'type'    => 'NUMERIC',
            ],
        ],
    ];

    // Filter by note type if specified
    if ($args['type'] !== 'all' && in_array($args['type'], ['private', 'customer'], true)) {
        $query_args['meta_query'][] = [
            'key'     => '_note_type',
            'value'   => $args['type'],
            'compare' => '=',
        ];
    }

    $query = new \WP_Query($query_args);

    return $query->posts;
}

/**
 * Get customer-visible notes only
 *
 * Convenience function to get only notes that should be visible to customers.
 *
 * @param int $subscription_id The subscription ID.
 * @return WP_Post[] Array of customer-visible note posts.
 */
function arraysubs_get_customer_notes(int $subscription_id): array
{
    return arraysubs_get_subscription_notes($subscription_id, [
        'type' => 'customer',
    ]);
}

/**
 * Delete a subscription note
 *
 * @param int $note_id The note ID.
 * @return bool True on success, false on failure.
 */
function arraysubs_delete_subscription_note(int $note_id): bool
{
    $note = get_post($note_id);

    if (!$note || $note->post_type !== 'arraysubs_sub_note') {
        return false;
    }

    $subscription_id = (int) get_post_meta($note_id, '_subscription_id', true);

    $result = wp_delete_post($note_id, true); // Force delete, no trash

    if ($result) {
        /**
         * Fires after a subscription note is deleted.
         *
         * @param int $note_id         The deleted note ID.
         * @param int $subscription_id The subscription ID.
         */
        do_action('arraysubs_note_deleted', $note_id, $subscription_id);
    }

    return (bool) $result;
}

/**
 * Get a single subscription note
 *
 * @param int $note_id The note ID.
 * @return WP_Post|null Note post object or null if not found.
 */
function arraysubs_get_subscription_note(int $note_id): ?\WP_Post
{
    $note = get_post($note_id);

    if (!$note || $note->post_type !== 'arraysubs_sub_note') {
        return null;
    }

    return $note;
}

/**
 * Get note count for a subscription
 *
 * @param int    $subscription_id The subscription ID.
 * @param string $type            Note type: 'all', 'private', or 'customer'. Default 'all'.
 * @return int Number of notes.
 */
function arraysubs_count_subscription_notes(int $subscription_id, string $type = 'all'): int
{
    $meta_query = [
        [
            'key'     => '_subscription_id',
            'value'   => $subscription_id,
            'compare' => '=',
            'type'    => 'NUMERIC',
        ],
    ];

    if ($type !== 'all' && in_array($type, ['private', 'customer'], true)) {
        $meta_query[] = [
            'key'     => '_note_type',
            'value'   => $type,
            'compare' => '=',
        ];
    }

    $query = new \WP_Query([
        'post_type'      => 'arraysubs_sub_note',
        'post_status'    => 'publish',
        'posts_per_page' => -1,
        'fields'         => 'ids',
        'meta_query'     => $meta_query,
    ]);

    return $query->found_posts;
}

/**
 * Format a note for display
 *
 * Transforms a note post into an array suitable for display or API response.
 *
 * @param WP_Post $note The note post object.
 * @return array Formatted note data.
 */
function arraysubs_format_note(WP_Post $note): array
{
    $added_by_id = (int) get_post_meta($note->ID, '_added_by', true);
    $is_system   = (bool) get_post_meta($note->ID, '_is_system', true);
    $note_type   = get_post_meta($note->ID, '_note_type', true) ?: 'private';
    $event_type  = sanitize_key((string) get_post_meta($note->ID, '_event_type', true));
    $subscription_id = (int) get_post_meta($note->ID, '_subscription_id', true);
    $date_gmt    = $note->post_date_gmt;
    $content     = $note->post_content;
    $content_raw = wp_strip_all_tags($note->post_content);
    $author_role = 'system';
    $author_label = __('System', 'arraysubs');
    $author_name = __('System', 'arraysubs');

    if (empty($date_gmt) || $date_gmt === '0000-00-00 00:00:00') {
        $date_gmt = get_gmt_from_date($note->post_date);
    }

    $date_timestamp = $date_gmt
        ? arraysubs_get_datetime_timestamp_from_string($date_gmt, new \DateTimeZone('UTC'))
        : null;

    if ($is_system && preg_match('/^\s*\[Gateway:\s*([^\]]+)\]\s*/i', $content_raw, $matches)) {
        $gateway_name = sanitize_text_field(trim($matches[1]));

        $author_role  = 'gateway';
        $author_label = __('Gateway', 'arraysubs');
        $author_name  = $gateway_name ?: __('Gateway', 'arraysubs');
        $content      = preg_replace('/^\s*\[Gateway:\s*([^\]]+)\]\s*/i', '', $content, 1) ?: $content;
        $content_raw  = preg_replace('/^\s*\[Gateway:\s*([^\]]+)\]\s*/i', '', $content_raw, 1) ?: $content_raw;
    } elseif (!$is_system && $added_by_id > 0) {
        $user = get_user_by('id', $added_by_id);

        if ($user instanceof \WP_User) {
            $author_name = $user->display_name;

            if (user_can($user, 'manage_woocommerce') || user_can($user, 'manage_options')) {
                $author_role  = 'admin';
                $author_label = __('Admin', 'arraysubs');
            } else {
                $author_role  = 'customer';
                $author_label = __('Customer', 'arraysubs');
            }
        } else {
            $author_role  = 'customer';
            $author_label = __('Customer', 'arraysubs');
            $author_name  = __('Unknown', 'arraysubs');
        }
    }

    $audit_changes = arraysubs_get_note_audit_changes($note->ID, $content_raw);
    $references    = arraysubs_get_note_references($subscription_id, $content_raw);
    $entity        = arraysubs_resolve_note_entity($note, $content_raw, $references);

    return [
        'id'              => $note->ID,
        'content'         => $content,
        'content_raw'     => $content_raw,
        'date'            => $note->post_date,
        'date_gmt'        => $date_gmt,
        'date_timestamp'  => $date_timestamp,
        'date_display'    => $date_gmt ? arraysubs_format_date_local($date_gmt) : '',
        'subscription_id' => $subscription_id,
        'note_type'       => $note_type,
        'event_type'      => $event_type,
        'added_by'        => $added_by_id,
        'author_name'     => $author_name,
        'author_role'     => $author_role,
        'author_label'    => $author_label,
        'is_system'       => $is_system,
        'entity'          => $entity,
        'references'      => $references,
        'audit_changes'   => $audit_changes,
        'has_audit_changes' => ! empty($audit_changes),
    ];
}

/**
 * Resolve the most appropriate entity type for a note.
 *
 * @param WP_Post $note        Note post object.
 * @param string  $content_raw Plain-text note content.
 * @param array   $references  Structured entity references.
 * @return string
 */
function arraysubs_resolve_note_entity(WP_Post $note, string $content_raw, array $references = []): string
{
    $valid_entities = ['subscription', 'member', 'product', 'order', 'coupon', 'email', 'settings', 'other'];
    $stored_entity  = sanitize_key((string) get_post_meta($note->ID, '_audit_entity', true));

    if (in_array($stored_entity, $valid_entities, true)) {
        return $stored_entity;
    }

    $content_entity  = arraysubs_classify_note_entity_from_content($content_raw);
    $reference_types = [];

    foreach ($references as $reference) {
        $type = sanitize_key((string) ($reference['type'] ?? ''));

        if ($type === '' || in_array($type, $reference_types, true)) {
            continue;
        }

        $reference_types[] = $type;
    }

    $has_object_reference = ! empty(array_intersect($reference_types, ['product', 'order', 'coupon', 'member', 'subscription']));

    if (in_array($content_entity, ['email', 'settings'], true)) {
        if ($content_entity === 'settings' && $has_object_reference) {
            // Object-specific settings/addresses/dates should follow the referenced entity, not global settings.
        } else {
            return $content_entity;
        }
    }

    if ($content_entity !== 'other') {
        return $content_entity;
    }

    if (count($reference_types) === 1) {
        $single_type = $reference_types[0];

        if (in_array($single_type, ['member', 'product', 'order', 'coupon', 'subscription'], true)) {
            return $single_type;
        }
    }

    foreach (['subscription', 'order', 'product', 'coupon', 'member'] as $preferred_type) {
        if (in_array($preferred_type, $reference_types, true)) {
            return $preferred_type;
        }
    }

    return $content_entity;
}

/**
 * Classify a note entity type based on its plain-text content.
 *
 * @param string $content_raw Plain-text note content.
 * @return string
 */
function arraysubs_classify_note_entity_from_content(string $content_raw): string
{
    $lower = strtolower($content_raw);

    if (
        str_contains($lower, 'member #')
        || str_contains($lower, 'customer data updated')
        || str_contains($lower, 'user data updated')
        || str_contains($lower, 'profile field changed')
        || str_contains($lower, 'account details updated')
    ) {
        return 'member';
    }

    if (
        str_contains($lower, 'order updated')
        || (str_contains($lower, 'order #') && str_contains($lower, 'status changed'))
        || str_contains($lower, 'order status changed')
        || str_contains($lower, 'payment successful')
        || str_contains($lower, 'payment failed')
        || str_contains($lower, 'payment method changed')
    ) {
        return 'order';
    }

    if (
        str_contains($lower, 'product changed')
        || str_contains($lower, 'product settings updated')
        || str_contains($lower, 'product created')
        || str_contains($lower, 'product updated')
        || str_contains($lower, 'product deleted')
        || str_contains($lower, 'product moved to trash')
        || str_contains($lower, 'product variation')
        || str_contains($lower, 'quantity changed')
        || str_contains($lower, 'recurring amount')
        || str_contains($lower, 'upgraded from')
        || str_contains($lower, 'downgraded from')
        || str_contains($lower, 'crossgraded from')
    ) {
        return 'product';
    }

    if (
        str_contains($lower, 'coupon')
        || str_contains($lower, 'discount cycles')
        || str_contains($lower, 'retention discount')
    ) {
        return 'coupon';
    }

    if (str_contains($lower, 'email sent')) {
        return 'email';
    }

    if (str_starts_with($lower, 'settings updated:') || str_contains($lower, ' settings updated:')) {
        return 'settings';
    }

    if (
        str_contains($lower, 'status changed')
        || str_contains($lower, 'subscription created')
        || str_contains($lower, 'reactivated')
        || str_contains($lower, 'on hold')
        || str_contains($lower, 'trial')
        || str_contains($lower, 'synchronized')
        || str_contains($lower, 'unsynced')
        || str_contains($lower, 'address updated')
        || str_contains($lower, 'dates updated')
        || str_contains($lower, 'auto-renewal')
        || str_contains($lower, 'renewal invoice')
        || str_contains($lower, 'renewal order')
        || str_contains($lower, 'renewal processed')
        || str_contains($lower, 'next payment date changed')
        || str_contains($lower, 'cancelled')
        || str_contains($lower, 'cancellation')
        || str_contains($lower, 'scheduled to cancel')
    ) {
        return 'subscription';
    }

    return 'other';
}

/**
 * Build a single structured audit change entry.
 *
 * @param string $label    Field label.
 * @param string $previous Previous display value.
 * @param string $changed  Changed display value.
 * @return array<string, string>
 */
function arraysubs_create_audit_change_entry(string $label, string $previous, string $changed): array
{
    return [
        'label' => sanitize_text_field($label),
        'previous_value' => arraysubs_sanitize_audit_display_value($previous),
        'changed_value' => arraysubs_sanitize_audit_display_value($changed),
    ];
}

/**
 * Sanitize an audit display value while preserving readable Unicode content.
 *
 * @param string $value Raw display value.
 * @return string
 */
function arraysubs_sanitize_audit_display_value(string $value): string
{
    $value = html_entity_decode($value, ENT_QUOTES, 'UTF-8');
    $value = preg_replace_callback(
        '/(?:\\\\u|u)([0-9a-fA-F]{4})/u',
        static function (array $matches): string {
            $decoded = json_decode('"\\u' . strtolower($matches[1]) . '"');

            return is_string($decoded) ? $decoded : $matches[0];
        },
        $value
    ) ?? $value;

    $value = str_replace(["\xc2\xa0", "\xa0", '&nbsp;'], ' ', $value);
    $value = preg_replace('/\s+/u', ' ', $value) ?? $value;
    $value = trim($value);

    return sanitize_text_field($value);
}

/**
 * Normalize structured audit changes for storage/output.
 *
 * @param array $changes Raw audit changes.
 * @return array<int, array<string, string>>
 */
function arraysubs_normalize_note_audit_changes(array $changes): array
{
    $normalized = [];

    foreach ($changes as $change) {
        if (! is_array($change)) {
            continue;
        }

        $label = sanitize_text_field((string) ($change['label'] ?? ''));
        $previous = arraysubs_sanitize_audit_display_value((string) ($change['previous_value'] ?? $change['old_value'] ?? $change['previous'] ?? ''));
        $current = arraysubs_sanitize_audit_display_value((string) ($change['changed_value'] ?? $change['new_value'] ?? $change['changed'] ?? ''));

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

        $normalized[] = [
            'label' => $label,
            'previous_value' => $previous !== '' ? $previous : '—',
            'changed_value' => $current !== '' ? $current : '—',
        ];
    }

    return $normalized;
}

/**
 * Persist note audit change data.
 *
 * @param int   $note_id  Note ID.
 * @param array $changes  Structured audit changes.
 * @return void
 */
function arraysubs_store_note_audit_changes(int $note_id, array $changes): void
{
    $normalized = arraysubs_normalize_note_audit_changes($changes);

    if (empty($normalized)) {
        delete_post_meta($note_id, '_audit_changes');
        return;
    }

    $json = wp_json_encode($normalized);

    if (is_string($json)) {
        update_post_meta($note_id, '_audit_changes', $json);
    }
}

/**
 * Get structured audit changes for a note.
 *
 * @param int    $note_id     Note ID.
 * @param string $content_raw Plain-text note content fallback source.
 * @return array<int, array<string, string>>
 */
function arraysubs_get_note_audit_changes(int $note_id, string $content_raw = ''): array
{
    $raw = get_post_meta($note_id, '_audit_changes', true);

    if (is_string($raw) && $raw !== '') {
        $decoded = json_decode($raw, true);

        if (is_array($decoded)) {
            return arraysubs_normalize_note_audit_changes($decoded);
        }
    }

    return arraysubs_parse_note_audit_changes_from_content($content_raw);
}

/**
 * Parse fallback audit changes from legacy note prose.
 *
 * @param string $content_raw Plain-text note content.
 * @return array<int, array<string, string>>
 */
function arraysubs_parse_note_audit_changes_from_content(string $content_raw): array
{
    if ($content_raw === '') {
        return [];
    }

    $segments = preg_split('/(?:•|\r?\n)+/u', $content_raw) ?: [];
    $changes  = [];

    foreach ($segments as $segment) {
        $segment = trim(wp_strip_all_tags((string) $segment));

        if ($segment === '' || !str_contains(strtolower($segment), ' changed from ')) {
            continue;
        }

        if (preg_match('/^(.+?) changed from "(.*?)" to "(.*?)"(?:\.| by .*)?$/u', $segment, $matches)) {
            $changes[] = arraysubs_create_audit_change_entry(
                $matches[1],
                $matches[2],
                $matches[3]
            );
            continue;
        }

        if (preg_match('/^(.+?) changed from (.+?) to (.+?)(?:\.| by .*)?$/u', $segment, $matches)) {
            $changes[] = arraysubs_create_audit_change_entry(
                $matches[1],
                $matches[2],
                $matches[3]
            );
        }
    }

    return arraysubs_normalize_note_audit_changes($changes);
}

/**
 * Build structured entity references for an audit note.
 *
 * @param int    $subscription_id Related subscription ID.
 * @param string $content_raw     Plain-text note content.
 * @return array<int, array<string, mixed>>
 */
function arraysubs_get_note_references(int $subscription_id, string $content_raw): array
{
    $references = [];

    if ($subscription_id > 0) {
        arraysubs_add_note_reference(
            $references,
            'subscription',
            $subscription_id,
            sprintf(
                /* translators: %d: subscription ID. */
                __('Subscription #%d', 'arraysubs'),
                $subscription_id
            ),
            '#/subscriptions/detail/' . $subscription_id
        );

        $customer_id = (int) get_post_meta($subscription_id, '_customer_id', true);
        if ($customer_id > 0) {
            arraysubs_add_note_reference(
                $references,
                'member',
                $customer_id,
                sprintf(
                    /* translators: %d: member ID. */
                    __('Member #%d', 'arraysubs'),
                    $customer_id
                ),
                '#/manage-members/' . $customer_id
            );
        }

        $product_id   = (int) get_post_meta($subscription_id, '_product_id', true);
        $variation_id = (int) get_post_meta($subscription_id, '_variation_id', true);
        $entity_id    = $variation_id > 0 ? $variation_id : $product_id;

        if ($entity_id > 0) {
            arraysubs_add_note_reference(
                $references,
                'product',
                $entity_id,
                sprintf(
                    /* translators: %d: product ID. */
                    __('Product #%d', 'arraysubs'),
                    $entity_id
                ),
                admin_url('post.php?post=' . $entity_id . '&action=edit')
            );
        }
    }

    $patterns = [
        'order' => '/Order\s+#(\d+)/i',
        'product' => '/Product(?:\s+variation)?\s+#(\d+)/i',
        'coupon' => '/Coupon\s+#(\d+)/i',
        'member' => '/(?:Member|Customer|User)\s+#(\d+)/i',
    ];

    foreach ($patterns as $type => $pattern) {
        if (!preg_match_all($pattern, $content_raw, $matches)) {
            continue;
        }

        foreach (array_unique($matches[1]) as $matched_id) {
            $entity_id = absint($matched_id);

            if ($entity_id <= 0) {
                continue;
            }

            $label = ucfirst($type) . ' #' . $entity_id;
            $href  = '';

            if ($type === 'order' || $type === 'product' || $type === 'coupon') {
                $href = admin_url('post.php?post=' . $entity_id . '&action=edit');
            } elseif ($type === 'member') {
                $href = '#/manage-members/' . $entity_id;
            }

            arraysubs_add_note_reference($references, $type, $entity_id, $label, $href);
        }
    }

    return array_values($references);
}

/**
 * Add a deduplicated note reference.
 *
 * @param array  $references Existing references.
 * @param string $type       Reference type.
 * @param int    $id         Entity ID.
 * @param string $label      Display label.
 * @param string $href       Destination URL.
 * @return void
 */
function arraysubs_add_note_reference(array &$references, string $type, int $id, string $label, string $href): void
{
    if ($id <= 0) {
        return;
    }

    $key = $type . ':' . $id;

    if (isset($references[$key])) {
        return;
    }

    $references[$key] = [
        'type'  => sanitize_key($type),
        'id'    => $id,
        'label' => sanitize_text_field($label),
        'href'  => esc_url_raw($href),
    ];
}

/**
 * Format a value for audit change messages.
 *
 * @param mixed  $value   Raw value.
 * @param string $context Formatting context.
 * @param array  $args    Optional context arguments.
 * @return string
 */
function arraysubs_format_audit_value($value, string $context = 'text', array $args = []): string
{
    if (is_bool($value)) {
        return $value ? __('Yes', 'arraysubs') : __('No', 'arraysubs');
    }

    if ($value === null || $value === '') {
        return '—';
    }

    if ($context === 'money') {
        $currency = sanitize_text_field((string) ($args['currency'] ?? ''));
        $price    = wc_price((float) $value, $currency ? ['currency' => $currency] : []);

        return arraysubs_sanitize_audit_display_value(wp_strip_all_tags(html_entity_decode($price, ENT_QUOTES, 'UTF-8')));
    }

    if ($context === 'date') {
        $value = (string) $value;

        return $value !== ''
            ? arraysubs_format_date_local($value)
            : '—';
    }

    if ($context === 'product') {
        $entity_id = absint((int) ($args['variation_id'] ?? 0) ?: (int) $value);

        if ($entity_id <= 0) {
            return '—';
        }

        $product = function_exists('wc_get_product') ? wc_get_product($entity_id) : null;
        $name    = $product ? arraysubs_get_subscription_product_name($product) : __('Unknown product', 'arraysubs');

        return sprintf(
            /* translators: 1: product name, 2: product ID. */
            __('%1$s (#%2$d)', 'arraysubs'),
            $name,
            $entity_id
        );
    }

    if ($context === 'payment_method') {
        $title = sanitize_text_field((string) ($args['title'] ?? ''));
        $slug  = sanitize_text_field((string) $value);

        if ($title !== '') {
            return sprintf(
                /* translators: 1: payment method title, 2: payment method slug. */
                __('%1$s (%2$s)', 'arraysubs'),
                $title,
                $slug !== '' ? $slug : __('manual', 'arraysubs')
            );
        }

        return $slug !== '' ? $slug : '—';
    }

    if (is_array($value)) {
        $json = wp_json_encode($value);

        return is_string($json) ? $json : '—';
    }

    return wp_strip_all_tags((string) $value);
}

/**
 * Format an old/new audit change sentence.
 *
 * @param string $label Audit field label.
 * @param string $old   Old value.
 * @param string $new   New value.
 * @return string
 */
function arraysubs_format_audit_change_sentence(string $label, string $old, string $new): string
{
    return sprintf(
        /* translators: 1: field label, 2: old value, 3: new value. */
        __('%1$s changed from "%2$s" to "%3$s".', 'arraysubs'),
        $label,
        $old,
        $new
    );
}

/**
 * Add a system note to a subscription
 *
 * Convenience function for auto-generated notes.
 *
 * @param int    $subscription_id The subscription ID.
 * @param string $content         Note content.
 * @param string $type            Note type: 'private' or 'customer'. Default 'customer'.
 * @param string $entity          Entity type for audit classification.
 * @param string $event_type      Structured event type for timeline/reporting classification.
 * @return int|WP_Error Note ID on success, WP_Error on failure.
 */
function arraysubs_add_system_note(
    int $subscription_id,
    string $content,
    string $type = 'customer',
    string $entity = '',
    string $event_type = ''
): int|\WP_Error {
    return arraysubs_add_subscription_note($subscription_id, $content, $type, 0, [], $entity, $event_type);
}

/**
 * Add a global note that is not tied to a specific subscription.
 *
 * @param string $content       Note content.
 * @param string $type          Note type: 'private' or 'customer'. Default 'private'.
 * @param int    $added_by      User ID who added the note. 0 for system.
 * @param array  $audit_changes Structured audit change entries.
 * @param string $entity        Entity type for audit classification.
 * @return int|WP_Error Note ID on success, WP_Error on failure.
 */
function arraysubs_add_global_note(
    string $content,
    string $type = 'private',
    int $added_by = 0,
    array $audit_changes = [],
    string $entity = ''
): int|\WP_Error {
    if ($entity !== '' && ! arraysubs_is_entity_logging_enabled($entity)) {
        return 0;
    }

    if (!in_array($type, ['private', 'customer'], true)) {
        $type = 'private';
    }

    $content = wp_kses_post($content);
    $content = make_clickable($content);

    $is_system = ($added_by === 0);

    $note_id = wp_insert_post([
        'post_type'    => 'arraysubs_sub_note',
        'post_status'  => 'publish',
        'post_content' => $content,
        'post_author'  => $added_by > 0 ? $added_by : get_current_user_id(),
        'post_parent'  => 0,
    ], true);

    if (is_wp_error($note_id)) {
        return $note_id;
    }

    update_post_meta($note_id, '_subscription_id', 0);
    update_post_meta($note_id, '_note_type', $type);
    update_post_meta($note_id, '_added_by', $added_by);
    update_post_meta($note_id, '_is_system', $is_system);
    arraysubs_store_note_audit_changes($note_id, $audit_changes);

    $entity = sanitize_key($entity);
    if ($entity !== '') {
        update_post_meta($note_id, '_audit_entity', $entity);
    }

    do_action('arraysubs_note_added', $note_id, 0, $content, $type, $added_by);

    return $note_id;
}

/**
 * Add a system-generated global note.
 *
 * @param string $content Note content.
 * @param string $type    Note type: 'private' or 'customer'. Default 'private'.
 * @param string $entity  Entity type for audit classification.
 * @return int|WP_Error Note ID on success, WP_Error on failure.
 */
function arraysubs_add_system_global_note(
    string $content,
    string $type = 'private',
    string $entity = ''
): int|\WP_Error {
    return arraysubs_add_global_note($content, $type, 0, [], $entity);
}

/**
 * Add a scheduled-job execution log note
 *
 * Creates a note with `_note_type = scheduled_job` that is independent of any
 * specific subscription.  Used by the Audits feature to record every Action
 * Scheduler run (success or failure) together with its arguments and a
 * human-readable summary.
 *
 * @param string $hook       The Action Scheduler hook name.
 * @param string $status     'success' or 'failed'.
 * @param string $summary    Two-line human-readable description of the run.
 * @param array  $args       The arguments that were passed to the action.
 * @param string $group      The Action Scheduler group.
 * @param string $error      Error message when status is 'failed'.
 * @return int|WP_Error      Note ID on success, WP_Error on failure.
 */
function arraysubs_add_scheduled_job_note(
    string $hook,
    string $status,
    string $summary,
    array $args = [],
    string $group = '',
    string $error = ''
): int|\WP_Error {
    $content = wp_kses_post($summary);

    $note_id = wp_insert_post(
        [
            'post_type'    => 'arraysubs_sub_note',
            'post_status'  => 'publish',
            'post_content' => $content,
            'post_author'  => 0,
            'post_parent'  => 0,
        ],
        true
    );

    if (is_wp_error($note_id)) {
        return $note_id;
    }

    $subscription_id = 0;
    if (! empty($args) && is_numeric($args[0])) {
        $sub = get_post((int) $args[0]);
        if ($sub && 'arraysubs_data' === $sub->post_type) {
            $subscription_id = (int) $args[0];
        }
    }

    update_post_meta($note_id, '_subscription_id', $subscription_id);
    update_post_meta($note_id, '_note_type', 'scheduled_job');
    update_post_meta($note_id, '_added_by', 0);
    update_post_meta($note_id, '_is_system', true);
    update_post_meta($note_id, '_job_hook', sanitize_text_field($hook));
    update_post_meta($note_id, '_job_status', in_array($status, ['success', 'failed'], true) ? $status : 'success');

    if ($group) {
        update_post_meta($note_id, '_job_group', sanitize_text_field($group));
    }

    if ($error) {
        update_post_meta($note_id, '_job_error', sanitize_text_field($error));
    }

    if (! empty($args)) {
        update_post_meta($note_id, '_job_args', wp_json_encode($args));
    }

    return $note_id;
}
