Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
95.72% covered (success)
95.72%
179 / 187
82.61% covered (warning)
82.61%
19 / 23
CRAP
0.00% covered (danger)
0.00%
0 / 1
Client
95.72% covered (success)
95.72%
179 / 187
82.61% covered (warning)
82.61%
19 / 23
76
0.00% covered (danger)
0.00%
0 / 1
 init
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 filter_http_request_args
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
10.04
 get_content
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 create_audio
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 update_audio
87.50% covered (success)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
3.02
 delete_audio
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 delete_audio_by_ids
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 batch_delete_audio
81.48% covered (warning)
81.48%
22 / 27
0.00% covered (danger)
0.00%
0 / 1
8.41
 get_player_by_source_id
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
2.00
 get_languages
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 get_voices
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 get_voice
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 get_video_settings
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 get_project
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 get_summarization_settings_templates
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 get_video_settings_templates
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 call_api
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
6
 build_args
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 cache_key
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 cached_get
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
5
 error_message_from_response
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
6
 delete_errors
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 save_error_message
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
7
1<?php
2/**
3 * BeyondWords REST API client: one method per endpoint, errors normalised into post meta.
4 *
5 * Auth and Content-Type headers are injected via the `http_request_args`
6 * filter, only for requests targeting the BeyondWords API host.
7 *
8 * @package BeyondWords\Api
9 * @since   3.0.0
10 * @since   7.0.0 Moved from BeyondWords\Core\ApiClient to BeyondWords\Api\Client.
11 */
12
13declare( strict_types = 1 );
14
15namespace BeyondWords\Api;
16
17defined( 'ABSPATH' ) || exit;
18
19/**
20 * BeyondWords API client.
21 *
22 * @since 7.0.0 Refactored to BeyondWords namespace with snake_case methods.
23 */
24class Client {
25
26    /**
27     * Format for the `beyondwords_error_message` post meta value.
28     *
29     * The HTTP-status prefix lets `Sync::update_or_recreate_audio()` recognise
30     * 404s by matching `#404:` without parsing the body.
31     */
32    const ERROR_FORMAT = '#%s: %s';
33
34    /**
35     * How long to cache editor dropdown data (languages, voices, templates, project settings).
36     */
37    const CACHE_TTL = 15 * MINUTE_IN_SECONDS;
38
39    /**
40     * How long to negative-cache a failed editor-dropdown fetch.
41     *
42     * Short enough that an outage self-heals within minutes, long enough that an
43     * unreachable API doesn't block every admin edit-screen render.
44     *
45     * @since 7.0.0
46     */
47    const CACHE_TTL_ON_ERROR = 2 * MINUTE_IN_SECONDS;
48
49    /**
50     * Default timeout, in seconds, for a BeyondWords API request.
51     *
52     * VIP's approved ceiling for a blocking remote request; content writes return
53     * immediately and generate audio server-side, so nothing needs longer.
54     *
55     * @since 7.0.0
56     */
57    const DEFAULT_REQUEST_TIMEOUT = 3;
58
59    /**
60     * Timeout, in seconds, for the voices GET — the one slow endpoint.
61     *
62     * Voices is ~3.7s p95 against ~250ms for every other GET, so the default
63     * timeout would abandon (and then negative-cache) many cold-cache fetches.
64     *
65     * @since 7.0.0
66     */
67    const VOICES_REQUEST_TIMEOUT = 8;
68
69    /**
70     * Register WordPress hooks.
71     *
72     * Must run early in the bootstrap so the filter precedes any API call.
73     */
74    public static function init(): void {
75        // The VIP warning targets raised timeouts; this filter only adds headers.
76        // phpcs:ignore WordPressVIPMinimum.Hooks.RestrictedHooks.http_request_args
77        add_filter( 'http_request_args', [ self::class, 'filter_http_request_args' ], 10, 2 );
78    }
79
80    /**
81     * Inject `X-Api-Key` and JSON `Content-Type` headers for BeyondWords API requests only.
82     *
83     * @param array<string,mixed> $args WordPress HTTP args.
84     * @param string              $url  Outbound request URL.
85     *
86     * @return array<string,mixed>
87     */
88    public static function filter_http_request_args( $args, $url ) {
89        if ( ! is_array( $args ) || ! is_string( $url ) ) {
90            return $args;
91        }
92
93        $api_url = \BeyondWords\Core\Urls::get_api_url();
94
95        if ( '' === $api_url || ! str_starts_with( $url, $api_url ) ) {
96            return $args;
97        }
98
99        $headers = isset( $args['headers'] ) && is_array( $args['headers'] ) ? $args['headers'] : [];
100
101        // Caller-supplied X-Api-Key wins (lets tests inject deliberately bad keys).
102        if ( ! isset( $headers['X-Api-Key'] ) ) {
103            $headers['X-Api-Key'] = (string) get_option( 'beyondwords_api_key', '' );
104        }
105
106        $method = strtoupper( (string) ( $args['method'] ?? 'GET' ) );
107
108        if (
109            in_array( $method, [ 'POST', 'PUT', 'DELETE' ], true )
110            && ! isset( $headers['Content-Type'] )
111        ) {
112            $headers['Content-Type'] = 'application/json';
113        }
114
115        $args['headers'] = $headers;
116
117        return $args;
118    }
119
120    /**
121     * GET /projects/:project/content/:content_id
122     *
123     * @param string          $content_id BeyondWords content ID.
124     * @param int|string|null $project_id Optional project ID override.
125     *
126     * @return array<mixed>|\WP_Error|false Raw HTTP response, WP_Error on transport
127     *                                      failure, or false when an ID is missing.
128     */
129    public static function get_content( int|string $content_id, int|string|null $project_id = null ): array|\WP_Error|false {
130        if ( ! $project_id ) {
131            $project_id = get_option( 'beyondwords_project_id' );
132        }
133
134        if ( ! $project_id || ! $content_id ) {
135            return false;
136        }
137
138        $url = sprintf( '%s/projects/%d/content/%s', \BeyondWords\Core\Urls::get_api_url(), $project_id, rawurlencode( (string) $content_id ) );
139
140        return self::call_api( 'GET', $url );
141    }
142
143    /**
144     * POST /projects/:project/content
145     *
146     * @param int $post_id WordPress post ID.
147     *
148     * @return array<mixed>|null|false Decoded response body, or false when the
149     *                                 post has no project ID.
150     */
151    public static function create_audio( int $post_id ): array|null|false {
152        $project_id = \BeyondWords\Post\Meta::get_project_id( $post_id );
153
154        if ( ! $project_id ) {
155            return false;
156        }
157
158        $url      = sprintf( '%s/projects/%d/content', \BeyondWords\Core\Urls::get_api_url(), $project_id );
159        $body     = \BeyondWords\Post\Content::get_content_params( $post_id );
160        $response = self::call_api( 'POST', $url, $body, $post_id );
161
162        return json_decode( wp_remote_retrieve_body( $response ), true );
163    }
164
165    /**
166     * PUT /projects/:project/content/:content_id
167     *
168     * Falls back to the post ID as the content ID for Magic Embed posts that
169     * never had a BeyondWords-issued ID.
170     *
171     * @param int $post_id WordPress post ID.
172     *
173     * @return array<mixed>|null|false Decoded response body, or false when an ID is missing.
174     */
175    public static function update_audio( int $post_id ): array|null|false {
176        $project_id = \BeyondWords\Post\Meta::get_project_id( $post_id );
177        $content_id = \BeyondWords\Post\Meta::get_content_id( $post_id, true );
178
179        if ( ! $project_id || ! $content_id ) {
180            return false;
181        }
182
183        $url      = sprintf( '%s/projects/%d/content/%s', \BeyondWords\Core\Urls::get_api_url(), $project_id, rawurlencode( (string) $content_id ) );
184        $body     = \BeyondWords\Post\Content::get_content_params( $post_id );
185        $response = self::call_api( 'PUT', $url, $body, $post_id );
186
187        return json_decode( wp_remote_retrieve_body( $response ), true );
188    }
189
190    /**
191     * DELETE /projects/:project/content/:content_id
192     *
193     * @param int $post_id WordPress post ID.
194     *
195     * @return array<mixed>|null|false `false` when the request didn't return 204.
196     */
197    public static function delete_audio( int $post_id ): array|null|false {
198        $project_id = \BeyondWords\Post\Meta::get_project_id( $post_id );
199        $content_id = \BeyondWords\Post\Meta::get_content_id( $post_id, true );
200
201        return self::delete_audio_by_ids( $project_id, $content_id, $post_id );
202    }
203
204    /**
205     * DELETE /projects/:project/content/:content_id using explicit IDs.
206     *
207     * Split out from `delete_audio()` so the deferred trash/delete cron job can
208     * still delete after the post meta has been wiped.
209     *
210     * @since 7.0.0
211     *
212     * @param int|string|false $project_id BeyondWords project ID.
213     * @param int|string|false $content_id BeyondWords content ID.
214     * @param int|false        $post_id    Optional post ID for error attribution.
215     *
216     * @return array<mixed>|null|false `false` when an ID is missing or the request didn't return 204.
217     */
218    public static function delete_audio_by_ids( int|string|false $project_id, int|string|false $content_id, int|false $post_id = false ): array|null|false {
219        if ( ! $project_id || ! $content_id ) {
220            return false;
221        }
222
223        $url      = sprintf( '%s/projects/%d/content/%s', \BeyondWords\Core\Urls::get_api_url(), $project_id, rawurlencode( (string) $content_id ) );
224        $response = self::call_api( 'DELETE', $url, '', $post_id );
225
226        if ( 204 !== wp_remote_retrieve_response_code( $response ) ) {
227            return false;
228        }
229
230        return json_decode( wp_remote_retrieve_body( $response ), true );
231    }
232
233    /**
234     * POST /projects/:project/content/batch_delete
235     *
236     * Refuses cross-project batches — the API only supports one project per request.
237     *
238     * @param int[] $post_ids WordPress post IDs.
239     *
240     * @return int[]|false Updated post IDs on success, empty array for non-OK responses.
241     *
242     * @throws \Exception When no posts have BeyondWords data, or multiple projects are mixed.
243     */
244    public static function batch_delete_audio( array $post_ids ): array|false {
245        $content_ids      = [];
246        $updated_post_ids = [];
247
248        foreach ( $post_ids as $post_id ) {
249            $project_id = \BeyondWords\Post\Meta::get_project_id( $post_id );
250            if ( ! $project_id ) {
251                continue;
252            }
253
254            $content_id = \BeyondWords\Post\Meta::get_content_id( $post_id );
255            if ( ! $content_id ) {
256                continue;
257            }
258
259            $content_ids[ $project_id ][] = $content_id;
260            $updated_post_ids[]           = $post_id;
261        }
262
263        if ( empty( $content_ids ) ) {
264            throw new \Exception(
265                esc_html__( 'None of the selected posts had valid BeyondWords audio data.', 'speechkit' )
266            );
267        }
268
269        if ( count( $content_ids ) > 1 ) {
270            throw new \Exception(
271                esc_html__( 'Batch delete can only be performed on audio belonging a single project.', 'speechkit' )
272            );
273        }
274
275        $project_id = array_key_first( $content_ids );
276        $url        = sprintf( '%s/projects/%d/content/batch_delete', \BeyondWords\Core\Urls::get_api_url(), $project_id );
277        $body       = (string) wp_json_encode( [ 'ids' => $content_ids[ $project_id ] ] );
278
279        $response = wp_remote_request( $url, self::build_args( 'POST', $body ) );
280
281        if ( is_wp_error( $response ) ) {
282            throw new \Exception( esc_html( $response->get_error_message() ) );
283        }
284
285        $response_code = wp_remote_retrieve_response_code( $response );
286
287        // On failure, return no IDs so the caller keeps local meta and can retry.
288        return $response_code <= 299 ? $updated_post_ids : [];
289    }
290
291    /**
292     * GET /projects/:project/player/by_source_id/:post_id
293     *
294     * Magic Embed bootstrap: BeyondWords looks up or creates content for the source URL.
295     *
296     * @param int $post_id WordPress post ID used as the source ID.
297     *
298     * @return array<mixed>|null|false
299     */
300    public static function get_player_by_source_id( int $post_id ): array|null|false {
301        $project_id = \BeyondWords\Post\Meta::get_project_id( $post_id );
302
303        if ( ! $project_id ) {
304            return false;
305        }
306
307        $url     = sprintf( '%s/projects/%d/player/by_source_id/%d', \BeyondWords\Core\Urls::get_api_url(), $project_id, $post_id );
308        $headers = [
309            'X-Import'  => 'true',
310            'X-Referer' => esc_url( get_permalink( $post_id ) ),
311        ];
312
313        $response = self::call_api( 'GET', $url, '', $post_id, $headers );
314
315        return json_decode( wp_remote_retrieve_body( $response ), true );
316    }
317
318    /**
319     * GET /organization/languages
320     *
321     * @return array<mixed>|null|false
322     */
323    public static function get_languages(): array|null|false {
324        $url = sprintf( '%s/organization/languages', \BeyondWords\Core\Urls::get_api_url() );
325
326        return self::cached_get( 'languages', $url );
327    }
328
329    /**
330     * GET /organization/voices?filter[language.code]=…
331     *
332     * @param int|string $language_code BeyondWords language code (or numeric ID).
333     *
334     * @return array<mixed>|null|false
335     */
336    public static function get_voices( int|string $language_code ): array|null|false {
337        $url = sprintf(
338            '%s/organization/voices?filter[language.code]=%s&filter[scopes][]=primary&filter[scopes][]=secondary',
339            \BeyondWords\Core\Urls::get_api_url(),
340            rawurlencode( strval( $language_code ) )
341        );
342
343        return self::cached_get( 'voices_' . $language_code, $url, self::VOICES_REQUEST_TIMEOUT );
344    }
345
346    /**
347     * Look up one voice by ID by listing all voices for a language.
348     *
349     * The API doesn't expose `/voices/:id`, so we fetch the list and filter.
350     *
351     * @param int              $voice_id      Voice ID.
352     * @param int|string|false $language_code Language code (required — no global fallback as of 7.0.0).
353     *
354     * @return object|array<mixed>|false Voice record, or false when missing.
355     */
356    public static function get_voice( int $voice_id, int|string|false $language_code = false ): object|array|false {
357        if ( ! $language_code ) {
358            return false;
359        }
360
361        $voices = self::get_voices( $language_code );
362
363        if ( empty( $voices ) ) {
364            return false;
365        }
366
367        return array_column( $voices, null, 'id' )[ $voice_id ] ?? false;
368    }
369
370    /**
371     * GET /projects/:id/video_settings
372     *
373     * @param int|null $project_id Optional override; falls back to the global option.
374     *
375     * @return array<mixed>|null|false
376     */
377    public static function get_video_settings( ?int $project_id = null ): array|null|false {
378        if ( ! $project_id ) {
379            $project_id = get_option( 'beyondwords_project_id' );
380
381            if ( ! $project_id ) {
382                return false;
383            }
384        }
385
386        $url = sprintf( '%s/projects/%d/video_settings', \BeyondWords\Core\Urls::get_api_url(), (int) $project_id );
387
388        return self::cached_get( 'video_settings_' . (int) $project_id, $url );
389    }
390
391    /**
392     * GET /projects/:id
393     *
394     * @since 7.0.0
395     *
396     * @param int|null $project_id Optional override; falls back to the global option.
397     *
398     * @return array<mixed>|null|false
399     */
400    public static function get_project( ?int $project_id = null ): array|null|false {
401        if ( ! $project_id ) {
402            $project_id = get_option( 'beyondwords_project_id' );
403
404            if ( ! $project_id ) {
405                return false;
406            }
407        }
408
409        $url = sprintf( '%s/projects/%d', \BeyondWords\Core\Urls::get_api_url(), (int) $project_id );
410
411        return self::cached_get( 'project_' . (int) $project_id, $url );
412    }
413
414    /**
415     * GET /summarization_settings_templates
416     *
417     * @since 7.0.0
418     *
419     * @return array<mixed>|null|false
420     */
421    public static function get_summarization_settings_templates(): array|null|false {
422        $url = sprintf( '%s/summarization_settings_templates', \BeyondWords\Core\Urls::get_api_url() );
423
424        return self::cached_get( 'summarization_settings_templates', $url );
425    }
426
427    /**
428     * GET /video_settings_templates
429     *
430     * @since 7.0.0
431     *
432     * @return array<mixed>|null|false
433     */
434    public static function get_video_settings_templates(): array|null|false {
435        $url = sprintf( '%s/video_settings_templates', \BeyondWords\Core\Urls::get_api_url() );
436
437        return self::cached_get( 'video_settings_templates', $url );
438    }
439
440    /**
441     * Make the API call, normalising errors into post meta when a post is supplied.
442     *
443     * A 401 also clears `beyondwords_valid_api_connection` so the settings page
444     * re-runs validation.
445     *
446     * @param string               $method  HTTP method.
447     * @param string               $url     Absolute URL.
448     * @param string               $body    Request body (already JSON-encoded for write methods).
449     * @param int|false            $post_id WordPress post ID for error attribution; false to suppress.
450     * @param array<string,string> $headers Extra per-request headers.
451     * @param int                  $timeout Request timeout in seconds. Defaults to DEFAULT_REQUEST_TIMEOUT.
452     */
453    public static function call_api( string $method, string $url, string $body = '', int|false $post_id = false, array $headers = [], int $timeout = self::DEFAULT_REQUEST_TIMEOUT ): array|\WP_Error {
454        $post = get_post( $post_id );
455
456        self::delete_errors( $post_id );
457
458        $response = wp_remote_request( $url, self::build_args( $method, $body, $headers, $timeout ) );
459
460        $response_code = wp_remote_retrieve_response_code( $response );
461
462        if ( 401 === $response_code ) {
463            delete_option( 'beyondwords_valid_api_connection' );
464        }
465
466        if (
467            $post instanceof \WP_Post
468            && \BeyondWords\Settings\Fields::INTEGRATION_REST_API === \BeyondWords\Settings\Fields::get_integration_method( $post )
469            && ( is_wp_error( $response ) || $response_code > 299 )
470        ) {
471            $message = self::error_message_from_response( $response );
472            self::save_error_message( $post_id, $message, $response_code );
473        }
474
475        return $response;
476    }
477
478    /**
479     * Build the WordPress HTTP args for a BeyondWords API call.
480     *
481     * Auth and Content-Type headers are added by `filter_http_request_args()`,
482     * not here, so they also apply to third-party calls against the API.
483     *
484     * @param string               $method  HTTP method.
485     * @param string               $body    Request body.
486     * @param array<string,string> $headers Extra per-request headers.
487     * @param int                  $timeout Request timeout in seconds.
488     *
489     * @return array<string,mixed>
490     */
491    private static function build_args( string $method, string $body = '', array $headers = [], int $timeout = self::DEFAULT_REQUEST_TIMEOUT ): array {
492        return [
493            'blocking' => true,
494            'body'     => $body,
495            'headers'  => $headers,
496            'method'   => strtoupper( $method ),
497            'timeout'  => $timeout,
498        ];
499    }
500
501    /**
502     * Build a transient key for a cached GET.
503     *
504     * Salted with the project ID + API key so changing either invalidates
505     * implicitly — no flush needed, which object-cache hosts can't do anyway.
506     *
507     * @since 7.0.0
508     *
509     * @param string $suffix Endpoint-specific key suffix.
510     */
511    private static function cache_key( string $suffix ): string {
512        $salt = substr(
513            md5( (string) get_option( 'beyondwords_project_id', '' ) . '|' . (string) get_option( 'beyondwords_api_key', '' ) ),
514            0,
515            12
516        );
517
518        return 'beyondwords_api_' . $suffix . '_' . $salt;
519    }
520
521    /**
522     * GET an editor-render-path endpoint, caching both hits and failures.
523     *
524     * Failures are negative-cached for the shorter {@see CACHE_TTL_ON_ERROR} so
525     * an unreachable API is probed at most once per interval, not every render.
526     *
527     * @since 7.0.0
528     *
529     * @param string $suffix  Cache-key suffix (include any project/language id).
530     * @param string $url     Absolute endpoint URL.
531     * @param int    $timeout Request timeout in seconds.
532     *
533     * @return array<mixed>|null|false Decoded body on the fetching call; the cached
534     *                                 value ([] after a cached failure) thereafter.
535     */
536    private static function cached_get( string $suffix, string $url, int $timeout = self::DEFAULT_REQUEST_TIMEOUT ): array|null|false {
537        $key    = self::cache_key( $suffix );
538        $cached = get_transient( $key );
539
540        if ( false !== $cached ) {
541            return $cached;
542        }
543
544        $response = self::call_api( 'GET', $url, '', false, [], $timeout );
545        $decoded  = json_decode( wp_remote_retrieve_body( $response ), true );
546
547        if (
548            ! is_wp_error( $response )
549            && wp_remote_retrieve_response_code( $response ) < 300
550            && is_array( $decoded )
551        ) {
552            set_transient( $key, $decoded, self::CACHE_TTL );
553
554            return $decoded;
555        }
556
557        set_transient( $key, [], self::CACHE_TTL_ON_ERROR );
558
559        return $decoded;
560    }
561
562    /**
563     * Pull a human-readable error string out of a BeyondWords API response.
564     *
565     * BeyondWords returns errors in two shapes — `errors[]` (validation) and
566     * `message` (other) — so we check both and fall back to the HTTP status text.
567     */
568    public static function error_message_from_response( array|\WP_Error $response ): string {
569        $body    = json_decode( wp_remote_retrieve_body( $response ), true );
570        $message = wp_remote_retrieve_response_message( $response );
571
572        if ( is_array( $body ) ) {
573            if ( array_key_exists( 'errors', $body ) ) {
574                $messages = [];
575                foreach ( $body['errors'] as $error ) {
576                    $messages[] = implode( ' ', array_values( $error ) );
577                }
578                $message = implode( ', ', $messages );
579            } elseif ( array_key_exists( 'message', $body ) ) {
580                // `message` is arbitrary JSON; coerce so the `: string` return
581                // type holds under strict_types.
582                $message = is_string( $body['message'] )
583                    ? $body['message']
584                    : (string) wp_json_encode( $body['message'] );
585            }
586        }
587
588        return $message;
589    }
590
591    /**
592     * Clear any error meta keys for a post.
593     *
594     * @param int|false $post_id WordPress post ID; false is a no-op.
595     */
596    public static function delete_errors( int|false $post_id ): void {
597        if ( ! $post_id ) {
598            return;
599        }
600
601        delete_post_meta( $post_id, 'speechkit_error_message' );
602        delete_post_meta( $post_id, 'beyondwords_error_message' );
603    }
604
605    /**
606     * Persist an error message to a post for surfacing in the editor.
607     *
608     * Skipped for Magic Embed 404s because client-side fetches retry on
609     * subsequent visits — surfacing a 404 here would be misleading.
610     *
611     * @param int|false  $post_id WordPress post ID; false is a no-op.
612     * @param string     $message Error message.
613     * @param int|string $code    HTTP status (or string code).
614     */
615    public static function save_error_message( int|false $post_id, string $message = '', int|string $code = 500 ): void {
616        if ( ! $post_id ) {
617            return;
618        }
619
620        $post = get_post( $post_id );
621
622        if (
623            404 === $code
624            && $post instanceof \WP_Post
625            && \BeyondWords\Settings\Fields::INTEGRATION_CLIENT_SIDE === \BeyondWords\Settings\Fields::get_integration_method( $post )
626        ) {
627            return;
628        }
629
630        if ( ! $message ) {
631            $message = sprintf(
632                /* translators: %s is replaced with the support email link */
633                esc_html__( 'API request error. Please contact %s.', 'speechkit' ),
634                '<a href="mailto:support@beyondwords.io">support@beyondwords.io</a>'
635            );
636        }
637
638        if ( ! $code ) {
639            $code = 500;
640        }
641
642        update_post_meta(
643            $post_id,
644            'beyondwords_error_message',
645            sprintf( self::ERROR_FORMAT, (string) $code, $message )
646        );
647    }
648}