Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
93.90% covered (success)
93.90%
231 / 246
74.07% covered (warning)
74.07%
20 / 27
CRAP
0.00% covered (danger)
0.00%
0 / 1
Client
93.90% covered (success)
93.90%
231 / 246
74.07% covered (warning)
74.07%
20 / 27
105.41
0.00% covered (danger)
0.00%
0 / 1
 init
100.00% covered (success)
100.00%
2 / 2
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%
10 / 10
100.00% covered (success)
100.00%
1 / 1
3
 adopt_existing_content
91.30% covered (success)
91.30%
21 / 23
0.00% covered (danger)
0.00%
0 / 1
11.08
 should_probe_after_transport_failure
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 is_this_sites_source_url
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 is_duplicate_source_id
88.89% covered (success)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
7.07
 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%
13 / 13
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%
23 / 23
100.00% covered (success)
100.00%
1 / 1
9
 delete_errors
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 save_error_message
78.95% covered (warning)
78.95%
15 / 19
0.00% covered (danger)
0.00%
0 / 1
7.46
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     * Kept short so synchronous save paths stay responsive; the slow endpoints
53     * get their own longer constants below.
54     *
55     * @since 7.0.0
56     */
57    const DEFAULT_REQUEST_TIMEOUT = 3;
58
59    /**
60     * Timeout, in seconds, for a content create.
61     *
62     * Creates have been observed exceeding the default 3s while the API still
63     * accepts the POST, stranding posts without a content ID. On VIP creates run
64     * async, so the longer wait never blocks a page load.
65     *
66     * @since 7.1.0
67     */
68    const CONTENT_REQUEST_TIMEOUT = 8;
69
70    /**
71     * Timeout, in seconds, for the post-create adoption probe.
72     *
73     * The probe only runs after a create outlived CONTENT_REQUEST_TIMEOUT, so
74     * healthy-path latency is the wrong yardstick — 2s buys a usable window
75     * against a degraded API while capping what it adds to a failing save.
76     *
77     * @since 7.1.0
78     */
79    const ADOPTION_PROBE_TIMEOUT = 2;
80
81    /**
82     * Timeout, in seconds, for the voices GET — the one slow endpoint.
83     *
84     * Voices is ~3.7s p95 against ~250ms for every other GET, so the default
85     * timeout would abandon (and then negative-cache) many cold-cache fetches.
86     *
87     * @since 7.0.0
88     */
89    const VOICES_REQUEST_TIMEOUT = 8;
90
91    /**
92     * API base URL, resolved once at bootstrap.
93     *
94     * @since 7.1.0
95     *
96     * @var string|null Null until `init()` runs.
97     */
98    private static ?string $api_url = null;
99
100    /**
101     * Register WordPress hooks.
102     *
103     * Must run early in the bootstrap so the filter precedes any API call.
104     */
105    public static function init(): void {
106        // WordPress fires http_request_args mid-upgrade, after the plugin files have
107        // been replaced, so resolve (and load) Urls now — a lazy autoload would fatal.
108        self::$api_url = \BeyondWords\Core\Urls::get_api_url();
109
110        // The VIP warning targets raised timeouts; this filter only adds headers.
111        // phpcs:ignore WordPressVIPMinimum.Hooks.RestrictedHooks.http_request_args
112        add_filter( 'http_request_args', [ self::class, 'filter_http_request_args' ], 10, 2 );
113    }
114
115    /**
116     * Inject `X-Api-Key` and JSON `Content-Type` headers for BeyondWords API requests only.
117     *
118     * @param array<string,mixed> $args WordPress HTTP args.
119     * @param string              $url  Outbound request URL.
120     *
121     * @return array<string,mixed>
122     */
123    public static function filter_http_request_args( $args, $url ) {
124        if ( ! is_array( $args ) || ! is_string( $url ) ) {
125            return $args;
126        }
127
128        $api_url = (string) self::$api_url;
129
130        if ( '' === $api_url || ! str_starts_with( $url, $api_url ) ) {
131            return $args;
132        }
133
134        $headers = isset( $args['headers'] ) && is_array( $args['headers'] ) ? $args['headers'] : [];
135
136        // Caller-supplied X-Api-Key wins (lets tests inject deliberately bad keys).
137        if ( ! isset( $headers['X-Api-Key'] ) ) {
138            $headers['X-Api-Key'] = (string) get_option( 'beyondwords_api_key', '' );
139        }
140
141        $method = strtoupper( (string) ( $args['method'] ?? 'GET' ) );
142
143        if (
144            in_array( $method, [ 'POST', 'PUT', 'DELETE' ], true )
145            && ! isset( $headers['Content-Type'] )
146        ) {
147            $headers['Content-Type'] = 'application/json';
148        }
149
150        $args['headers'] = $headers;
151
152        return $args;
153    }
154
155    /**
156     * GET /projects/:project/content/:content_id
157     *
158     * @return array<mixed>|\WP_Error|false Raw HTTP response, WP_Error on transport
159     *                                      failure, or false when an ID is missing.
160     */
161    public static function get_content( int|string $content_id, int|string|null $project_id = null, int $timeout = self::DEFAULT_REQUEST_TIMEOUT ): array|\WP_Error|false {
162        if ( ! $project_id ) {
163            $project_id = get_option( 'beyondwords_project_id' );
164        }
165
166        if ( ! $project_id || ! $content_id ) {
167            return false;
168        }
169
170        $url = sprintf( '%s/projects/%d/content/%s', \BeyondWords\Core\Urls::get_api_url(), $project_id, rawurlencode( (string) $content_id ) );
171
172        return self::call_api( 'GET', $url, '', false, [], $timeout );
173    }
174
175    /**
176     * POST /projects/:project/content
177     *
178     * @param int $post_id WordPress post ID.
179     *
180     * @return array<mixed>|null|false Decoded response body — possibly an adopted
181     *                                 existing record, see doc/source-id-race.md —
182     *                                 null when the create failed, or false when
183     *                                 the post has no project ID.
184     */
185    public static function create_audio( int $post_id ): array|null|false {
186        $project_id = \BeyondWords\Post\Meta::get_project_id( $post_id );
187
188        if ( ! $project_id ) {
189            return false;
190        }
191
192        $url      = sprintf( '%s/projects/%d/content', \BeyondWords\Core\Urls::get_api_url(), $project_id );
193        $body     = \BeyondWords\Post\Content::get_content_params( $post_id );
194        $response = self::call_api( 'POST', $url, $body, $post_id, [], self::CONTENT_REQUEST_TIMEOUT );
195
196        $existing = self::adopt_existing_content( $response, $post_id, $project_id );
197
198        if ( null !== $existing ) {
199            return $existing;
200        }
201
202        return json_decode( wp_remote_retrieve_body( $response ), true );
203    }
204
205    /**
206     * Recover content the create may already have written (422 race or transport loss).
207     *
208     * See doc/source-id-race.md.
209     *
210     * @since 7.0.0
211     *
212     * @param int $post_id WordPress post ID, which is also the content's source ID.
213     *
214     * @return array<mixed>|null Null when adoption does not apply, or the content
215     *                           couldn't be confirmed as this post's.
216     */
217    private static function adopt_existing_content( array|\WP_Error $response, int $post_id, int|string $project_id ): ?array {
218        $should_probe = is_wp_error( $response )
219            ? self::should_probe_after_transport_failure( $response )
220            : self::is_duplicate_source_id( $response );
221
222        if ( ! $should_probe ) {
223            return null;
224        }
225
226        $existing = self::get_content( $post_id, $project_id, self::ADOPTION_PROBE_TIMEOUT );
227
228        if ( is_wp_error( $existing ) ) {
229            // The API is unreachable; stop paying for a probe on every save.
230            set_transient( self::cache_key( 'adopt_probe_down' ), 1, self::CACHE_TTL_ON_ERROR );
231
232            return null;
233        }
234
235        if ( ! is_array( $existing ) ) {
236            return null;
237        }
238
239        $code = (int) wp_remote_retrieve_response_code( $existing );
240
241        if ( $code < 200 || $code > 299 ) {
242            return null;
243        }
244
245        $content = json_decode( wp_remote_retrieve_body( $existing ), true );
246
247        if ( ! is_array( $content ) || empty( $content['id'] ) ) {
248            return null;
249        }
250
251        // The lookup also resolves legacy numeric content IDs; require this post's source ID.
252        if ( (string) $post_id !== (string) ( $content['source_id'] ?? '' ) ) {
253            return null;
254        }
255
256        if ( ! self::is_this_sites_source_url( (string) ( $content['source_url'] ?? '' ) ) ) {
257            return null;
258        }
259
260        // The create only failed because the content already exists (or the reply was lost).
261        self::delete_errors( $post_id );
262
263        return $content;
264    }
265
266    /**
267     * Whether a transport-failed create warrants probing for an accepted record.
268     *
269     * @since 7.1.0
270     */
271    private static function should_probe_after_transport_failure( \WP_Error $error ): bool {
272        // The request never left WordPress, so nothing can have been accepted.
273        if ( 'http_request_not_executed' === $error->get_error_code() ) {
274            return false;
275        }
276
277        return ! get_transient( self::cache_key( 'adopt_probe_down' ) );
278    }
279
280    /**
281     * Whether a content record's source URL belongs to this install.
282     *
283     * Host and path-segment comparison; scheme and port are ignored so a site
284     * that moved from http to https still owns its pre-move content. A root-path
285     * install cannot be told apart from a subdirectory install on the same host
286     * by URL alone — see doc/source-id-race.md.
287     *
288     * @since 7.1.0
289     */
290    private static function is_this_sites_source_url( string $source_url ): bool {
291        $site   = wp_parse_url( home_url() );
292        $source = wp_parse_url( $source_url );
293
294        $site_host   = strtolower( (string) ( $site['host'] ?? '' ) );
295        $source_host = strtolower( (string) ( $source['host'] ?? '' ) );
296
297        if ( '' === $site_host || $site_host !== $source_host ) {
298            return false;
299        }
300
301        $site_path   = trailingslashit( (string) ( $site['path'] ?? '/' ) );
302        $source_path = trailingslashit( (string) ( $source['path'] ?? '/' ) );
303
304        return str_starts_with( $source_path, $site_path );
305    }
306
307    /**
308     * Whether a create response is the API rejecting an already-used `source_id`.
309     *
310     * @since 7.0.0
311     */
312    private static function is_duplicate_source_id( array $response ): bool {
313        if ( 422 !== (int) wp_remote_retrieve_response_code( $response ) ) {
314            return false;
315        }
316
317        $body = json_decode( wp_remote_retrieve_body( $response ), true );
318
319        if ( ! is_array( $body ) || ! is_array( $body['errors'] ?? null ) ) {
320            return false;
321        }
322
323        foreach ( $body['errors'] as $error ) {
324            // Matched on `location`; the message beside it is free-form.
325            if ( is_array( $error ) && 'source_id' === ( $error['location'] ?? '' ) ) {
326                return true;
327            }
328        }
329
330        return false;
331    }
332
333    /**
334     * PUT /projects/:project/content/:content_id
335     *
336     * Falls back to the post ID as the content ID for Magic Embed posts that
337     * never had a BeyondWords-issued ID.
338     *
339     * @param int $post_id WordPress post ID.
340     *
341     * @return array<mixed>|null|false Decoded response body, or false when an ID is missing.
342     */
343    public static function update_audio( int $post_id ): array|null|false {
344        $project_id = \BeyondWords\Post\Meta::get_project_id( $post_id );
345        $content_id = \BeyondWords\Post\Meta::get_content_id( $post_id, true );
346
347        if ( ! $project_id || ! $content_id ) {
348            return false;
349        }
350
351        $url      = sprintf( '%s/projects/%d/content/%s', \BeyondWords\Core\Urls::get_api_url(), $project_id, rawurlencode( (string) $content_id ) );
352        $body     = \BeyondWords\Post\Content::get_content_params( $post_id );
353        $response = self::call_api( 'PUT', $url, $body, $post_id );
354
355        return json_decode( wp_remote_retrieve_body( $response ), true );
356    }
357
358    /**
359     * DELETE /projects/:project/content/:content_id
360     *
361     * @param int $post_id WordPress post ID.
362     *
363     * @return array<mixed>|null|false `false` when the request didn't return 204.
364     */
365    public static function delete_audio( int $post_id ): array|null|false {
366        $project_id = \BeyondWords\Post\Meta::get_project_id( $post_id );
367        $content_id = \BeyondWords\Post\Meta::get_content_id( $post_id, true );
368
369        return self::delete_audio_by_ids( $project_id, $content_id, $post_id );
370    }
371
372    /**
373     * DELETE /projects/:project/content/:content_id using explicit IDs.
374     *
375     * Split out from `delete_audio()` so the deferred trash/delete cron job can
376     * still delete after the post meta has been wiped.
377     *
378     * @since 7.0.0
379     *
380     * @param int|string|false $project_id BeyondWords project ID.
381     * @param int|string|false $content_id BeyondWords content ID.
382     * @param int|false        $post_id    Optional post ID for error attribution.
383     *
384     * @return array<mixed>|null|false `false` when an ID is missing or the request didn't return 204.
385     */
386    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 {
387        if ( ! $project_id || ! $content_id ) {
388            return false;
389        }
390
391        $url      = sprintf( '%s/projects/%d/content/%s', \BeyondWords\Core\Urls::get_api_url(), $project_id, rawurlencode( (string) $content_id ) );
392        $response = self::call_api( 'DELETE', $url, '', $post_id );
393
394        if ( 204 !== wp_remote_retrieve_response_code( $response ) ) {
395            return false;
396        }
397
398        return json_decode( wp_remote_retrieve_body( $response ), true );
399    }
400
401    /**
402     * POST /projects/:project/content/batch_delete
403     *
404     * Refuses cross-project batches — the API only supports one project per request.
405     *
406     * @param int[] $post_ids WordPress post IDs.
407     *
408     * @return int[]|false Updated post IDs on success, empty array for non-OK responses.
409     *
410     * @throws \Exception When no posts have BeyondWords data, or multiple projects are mixed.
411     */
412    public static function batch_delete_audio( array $post_ids ): array|false {
413        $content_ids      = [];
414        $updated_post_ids = [];
415
416        foreach ( $post_ids as $post_id ) {
417            $project_id = \BeyondWords\Post\Meta::get_project_id( $post_id );
418            if ( ! $project_id ) {
419                continue;
420            }
421
422            $content_id = \BeyondWords\Post\Meta::get_content_id( $post_id );
423            if ( ! $content_id ) {
424                continue;
425            }
426
427            $content_ids[ $project_id ][] = $content_id;
428            $updated_post_ids[]           = $post_id;
429        }
430
431        if ( empty( $content_ids ) ) {
432            throw new \Exception(
433                esc_html__( 'None of the selected posts had valid BeyondWords audio data.', 'speechkit' )
434            );
435        }
436
437        if ( count( $content_ids ) > 1 ) {
438            throw new \Exception(
439                esc_html__( 'Batch delete can only be performed on audio belonging a single project.', 'speechkit' )
440            );
441        }
442
443        $project_id = array_key_first( $content_ids );
444        $url        = sprintf( '%s/projects/%d/content/batch_delete', \BeyondWords\Core\Urls::get_api_url(), $project_id );
445        $body       = (string) wp_json_encode( [ 'ids' => $content_ids[ $project_id ] ] );
446
447        $response = wp_remote_request( $url, self::build_args( 'POST', $body ) );
448
449        if ( is_wp_error( $response ) ) {
450            throw new \Exception( esc_html( $response->get_error_message() ) );
451        }
452
453        $response_code = wp_remote_retrieve_response_code( $response );
454
455        // On failure, return no IDs so the caller keeps local meta and can retry.
456        return $response_code <= 299 ? $updated_post_ids : [];
457    }
458
459    /**
460     * GET /projects/:project/player/by_source_id/:post_id
461     *
462     * Magic Embed bootstrap: BeyondWords looks up or creates content for the source URL.
463     *
464     * @param int $post_id WordPress post ID used as the source ID.
465     *
466     * @return array<mixed>|null|false
467     */
468    public static function get_player_by_source_id( int $post_id ): array|null|false {
469        $project_id = \BeyondWords\Post\Meta::get_project_id( $post_id );
470
471        if ( ! $project_id ) {
472            return false;
473        }
474
475        $url     = sprintf( '%s/projects/%d/player/by_source_id/%d', \BeyondWords\Core\Urls::get_api_url(), $project_id, $post_id );
476        $headers = [
477            'X-Import'  => 'true',
478            'X-Referer' => esc_url( get_permalink( $post_id ) ),
479        ];
480
481        $response = self::call_api( 'GET', $url, '', $post_id, $headers );
482
483        return json_decode( wp_remote_retrieve_body( $response ), true );
484    }
485
486    /**
487     * GET /organization/languages
488     *
489     * @return array<mixed>|null|false
490     */
491    public static function get_languages(): array|null|false {
492        $url = sprintf( '%s/organization/languages', \BeyondWords\Core\Urls::get_api_url() );
493
494        return self::cached_get( 'languages', $url );
495    }
496
497    /**
498     * GET /organization/voices?filter[language.code]=…
499     *
500     * @param int|string $language_code BeyondWords language code (or numeric ID).
501     *
502     * @return array<mixed>|null|false
503     */
504    public static function get_voices( int|string $language_code ): array|null|false {
505        $url = sprintf(
506            '%s/organization/voices?filter[language.code]=%s&filter[scopes][]=primary&filter[scopes][]=secondary',
507            \BeyondWords\Core\Urls::get_api_url(),
508            rawurlencode( strval( $language_code ) )
509        );
510
511        return self::cached_get( 'voices_' . $language_code, $url, self::VOICES_REQUEST_TIMEOUT );
512    }
513
514    /**
515     * Look up one voice by ID by listing all voices for a language.
516     *
517     * The API doesn't expose `/voices/:id`, so we fetch the list and filter.
518     *
519     * @param int              $voice_id      Voice ID.
520     * @param int|string|false $language_code Language code (required — no global fallback as of 7.0.0).
521     *
522     * @return object|array<mixed>|false Voice record, or false when missing.
523     */
524    public static function get_voice( int $voice_id, int|string|false $language_code = false ): object|array|false {
525        if ( ! $language_code ) {
526            return false;
527        }
528
529        $voices = self::get_voices( $language_code );
530
531        if ( empty( $voices ) ) {
532            return false;
533        }
534
535        return array_column( $voices, null, 'id' )[ $voice_id ] ?? false;
536    }
537
538    /**
539     * GET /projects/:id/video_settings
540     *
541     * @param int|null $project_id Optional override; falls back to the global option.
542     *
543     * @return array<mixed>|null|false
544     */
545    public static function get_video_settings( ?int $project_id = null ): array|null|false {
546        if ( ! $project_id ) {
547            $project_id = get_option( 'beyondwords_project_id' );
548
549            if ( ! $project_id ) {
550                return false;
551            }
552        }
553
554        $url = sprintf( '%s/projects/%d/video_settings', \BeyondWords\Core\Urls::get_api_url(), (int) $project_id );
555
556        return self::cached_get( 'video_settings_' . (int) $project_id, $url );
557    }
558
559    /**
560     * GET /projects/:id
561     *
562     * @since 7.0.0
563     *
564     * @param int|null $project_id Optional override; falls back to the global option.
565     *
566     * @return array<mixed>|null|false
567     */
568    public static function get_project( ?int $project_id = null ): array|null|false {
569        if ( ! $project_id ) {
570            $project_id = get_option( 'beyondwords_project_id' );
571
572            if ( ! $project_id ) {
573                return false;
574            }
575        }
576
577        $url = sprintf( '%s/projects/%d', \BeyondWords\Core\Urls::get_api_url(), (int) $project_id );
578
579        return self::cached_get( 'project_' . (int) $project_id, $url );
580    }
581
582    /**
583     * GET /summarization_settings_templates
584     *
585     * @since 7.0.0
586     *
587     * @return array<mixed>|null|false
588     */
589    public static function get_summarization_settings_templates(): array|null|false {
590        $url = sprintf( '%s/summarization_settings_templates', \BeyondWords\Core\Urls::get_api_url() );
591
592        return self::cached_get( 'summarization_settings_templates', $url );
593    }
594
595    /**
596     * GET /video_settings_templates
597     *
598     * @since 7.0.0
599     *
600     * @return array<mixed>|null|false
601     */
602    public static function get_video_settings_templates(): array|null|false {
603        $url = sprintf( '%s/video_settings_templates', \BeyondWords\Core\Urls::get_api_url() );
604
605        return self::cached_get( 'video_settings_templates', $url );
606    }
607
608    /**
609     * Make the API call, normalising errors into post meta when a post is supplied.
610     *
611     * A 401 also clears `beyondwords_valid_api_connection` so the settings page
612     * re-runs validation.
613     *
614     * @param string               $method  HTTP method.
615     * @param string               $url     Absolute URL.
616     * @param string               $body    Request body (already JSON-encoded for write methods).
617     * @param int|false            $post_id WordPress post ID for error attribution; false to suppress.
618     * @param array<string,string> $headers Extra per-request headers.
619     * @param int                  $timeout Request timeout in seconds. Defaults to DEFAULT_REQUEST_TIMEOUT.
620     */
621    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 {
622        $post = get_post( $post_id );
623
624        self::delete_errors( $post_id );
625
626        $response = wp_remote_request( $url, self::build_args( $method, $body, $headers, $timeout ) );
627
628        $response_code = (int) wp_remote_retrieve_response_code( $response );
629
630        if ( 401 === $response_code ) {
631            delete_option( 'beyondwords_valid_api_connection' );
632            // Drop the recent-check cache too, so the settings page revalidates immediately.
633            delete_transient( \BeyondWords\Settings\Utils::CONNECTION_CHECK_TRANSIENT );
634        }
635
636        if (
637            $post instanceof \WP_Post
638            && \BeyondWords\Settings\Fields::INTEGRATION_REST_API === \BeyondWords\Settings\Fields::get_integration_method( $post )
639            && ( is_wp_error( $response ) || $response_code > 299 )
640        ) {
641            $message = self::error_message_from_response( $response );
642            self::save_error_message( $post_id, $message, $response_code );
643        }
644
645        return $response;
646    }
647
648    /**
649     * Build the WordPress HTTP args for a BeyondWords API call.
650     *
651     * Auth and Content-Type headers are added by `filter_http_request_args()`,
652     * not here, so they also apply to third-party calls against the API.
653     *
654     * @param string               $method  HTTP method.
655     * @param string               $body    Request body.
656     * @param array<string,string> $headers Extra per-request headers.
657     * @param int                  $timeout Request timeout in seconds.
658     *
659     * @return array<string,mixed>
660     */
661    private static function build_args( string $method, string $body = '', array $headers = [], int $timeout = self::DEFAULT_REQUEST_TIMEOUT ): array {
662        return [
663            'blocking' => true,
664            'body'     => $body,
665            'headers'  => $headers,
666            'method'   => strtoupper( $method ),
667            'timeout'  => $timeout,
668        ];
669    }
670
671    /**
672     * Build a transient key for a cached GET.
673     *
674     * Salted with the project ID + API key so changing either invalidates
675     * implicitly — no flush needed, which object-cache hosts can't do anyway.
676     *
677     * @since 7.0.0
678     *
679     * @param string $suffix Endpoint-specific key suffix.
680     */
681    private static function cache_key( string $suffix ): string {
682        $salt = substr(
683            md5( (string) get_option( 'beyondwords_project_id', '' ) . '|' . (string) get_option( 'beyondwords_api_key', '' ) ),
684            0,
685            12
686        );
687
688        return 'beyondwords_api_' . $suffix . '_' . $salt;
689    }
690
691    /**
692     * GET an editor-render-path endpoint, caching both hits and failures.
693     *
694     * Failures are negative-cached for the shorter {@see CACHE_TTL_ON_ERROR} so
695     * an unreachable API is probed at most once per interval, not every render.
696     *
697     * @since 7.0.0
698     *
699     * @param string $suffix  Cache-key suffix (include any project/language id).
700     * @param string $url     Absolute endpoint URL.
701     * @param int    $timeout Request timeout in seconds.
702     *
703     * @return array<mixed>|null|false Decoded body on the fetching call; the cached
704     *                                 value ([] after a cached failure) thereafter.
705     */
706    private static function cached_get( string $suffix, string $url, int $timeout = self::DEFAULT_REQUEST_TIMEOUT ): array|null|false {
707        $key    = self::cache_key( $suffix );
708        $cached = get_transient( $key );
709
710        if ( false !== $cached ) {
711            return $cached;
712        }
713
714        $response = self::call_api( 'GET', $url, '', false, [], $timeout );
715        $decoded  = json_decode( wp_remote_retrieve_body( $response ), true );
716
717        if (
718            ! is_wp_error( $response )
719            && wp_remote_retrieve_response_code( $response ) < 300
720            && is_array( $decoded )
721        ) {
722            set_transient( $key, $decoded, self::CACHE_TTL );
723
724            return $decoded;
725        }
726
727        set_transient( $key, [], self::CACHE_TTL_ON_ERROR );
728
729        return $decoded;
730    }
731
732    /**
733     * Pull a human-readable error string out of a BeyondWords API response.
734     *
735     * BeyondWords returns errors in two shapes — `errors[]` (validation) and
736     * `message` (other) — so we check both and fall back to the HTTP status text.
737     */
738    public static function error_message_from_response( array|\WP_Error $response ): string {
739        if ( is_wp_error( $response ) ) {
740            $detail = $response->get_error_message();
741            // Filters can attach non-string messages; coerce so the `: string` return holds.
742            $detail = is_string( $detail ) ? $detail : (string) wp_json_encode( $detail );
743
744            // An empty detail falls through to save_error_message()'s generic fallback.
745            if ( '' === $detail ) {
746                return '';
747            }
748
749            return sprintf(
750                /* translators: %1$s is replaced with the support email link, %2$s with the transport error detail. */
751                esc_html__( 'API request error. Please contact %1$s. (%2$s)', 'speechkit' ),
752                '<a href="mailto:support@beyondwords.io">support@beyondwords.io</a>',
753                $detail
754            );
755        }
756
757        $body    = json_decode( wp_remote_retrieve_body( $response ), true );
758        $message = wp_remote_retrieve_response_message( $response );
759
760        if ( is_array( $body ) ) {
761            if ( array_key_exists( 'errors', $body ) ) {
762                $messages = [];
763                foreach ( $body['errors'] as $error ) {
764                    $messages[] = implode( ' ', array_values( $error ) );
765                }
766                $message = implode( ', ', $messages );
767            } elseif ( array_key_exists( 'message', $body ) ) {
768                // `message` is arbitrary JSON; coerce so the `: string` return
769                // type holds under strict_types.
770                $message = is_string( $body['message'] )
771                    ? $body['message']
772                    : (string) wp_json_encode( $body['message'] );
773            }
774        }
775
776        return $message;
777    }
778
779    /**
780     * Clear any error meta keys for a post.
781     *
782     * @param int|false $post_id WordPress post ID; false is a no-op.
783     */
784    public static function delete_errors( int|false $post_id ): void {
785        if ( ! $post_id ) {
786            return;
787        }
788
789        delete_post_meta( $post_id, 'speechkit_error_message' );
790        delete_post_meta( $post_id, 'beyondwords_error_message' );
791    }
792
793    /**
794     * Persist an error message to a post for surfacing in the editor.
795     *
796     * Skipped for Magic Embed 404s because client-side fetches retry on
797     * subsequent visits — surfacing a 404 here would be misleading.
798     *
799     * @param int|false  $post_id WordPress post ID; false is a no-op.
800     * @param string     $message Error message.
801     * @param int|string $code    HTTP status (or string code).
802     */
803    public static function save_error_message( int|false $post_id, string $message = '', int|string $code = 500 ): void {
804        if ( ! $post_id ) {
805            return;
806        }
807
808        $post = get_post( $post_id );
809
810        if (
811            404 === $code
812            && $post instanceof \WP_Post
813            && \BeyondWords\Settings\Fields::INTEGRATION_CLIENT_SIDE === \BeyondWords\Settings\Fields::get_integration_method( $post )
814        ) {
815            return;
816        }
817
818        if ( ! $message ) {
819            $message = sprintf(
820                /* translators: %s is replaced with the support email link */
821                esc_html__( 'API request error. Please contact %s.', 'speechkit' ),
822                '<a href="mailto:support@beyondwords.io">support@beyondwords.io</a>'
823            );
824        }
825
826        if ( ! $code ) {
827            $code = 500;
828        }
829
830        update_post_meta(
831            $post_id,
832            'beyondwords_error_message',
833            sprintf( self::ERROR_FORMAT, (string) $code, $message )
834        );
835    }
836}