<?php
if ( ! defined( 'ABSPATH' ) ) exit;

class Yatoon_SF_Sync {

    /**
     * How many consecutive failed syncs before we treat a token as
     * "probably expired" and surface an admin warning about it.
     */
    const FAIL_THRESHOLD = 3;

    /**
     * Transient key used as a simple mutex so a manual "Sync now" click
     * and the WP-Cron auto-sync never run concurrently against the same
     * Graph API credentials.
     */
    const LOCK_KEY = 'yatoon_sf_sync_lock';

    public function __construct() {
        add_action( 'yatoon_sf_auto_sync',           array( $this, 'run_auto_sync' ) );
        add_action( 'init',                         array( $this, 'maybe_schedule_cron' ) );
        add_action( 'wp_ajax_yatoon_sf_manual_sync',  array( $this, 'ajax_manual_sync' ) );
        add_action( 'wp_ajax_yatoon_sf_verify_token', array( $this, 'ajax_verify_token' ) );
        add_action( 'wp_ajax_yatoon_sf_verify_ig_token', array( $this, 'ajax_verify_ig_token' ) );
    }

    /* ---------------------------------------------------------------  Cron */

    public function maybe_schedule_cron() {
        $freq = get_option( 'yatoon_sf_auto_sync', 'daily' );
        if ( $freq === 'off' ) {
            wp_clear_scheduled_hook( 'yatoon_sf_auto_sync' );
            return;
        }
        if ( ! wp_next_scheduled( 'yatoon_sf_auto_sync' ) ) {
            wp_schedule_event( time(), $freq, 'yatoon_sf_auto_sync' );
        }
    }

    /* ---------------------------------------------------------------  Verify token AJAX */

    public function ajax_verify_token() {
        check_ajax_referer( 'yatoon_sf_nonce', 'nonce' );
        if ( ! current_user_can( 'manage_options' ) ) wp_send_json_error( 'Permission denied.' );

        $token = isset( $_POST['token'] ) ? sanitize_text_field( wp_unslash( $_POST['token'] ) ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
        // Strip any whitespace/newlines that may have been pasted in accidentally
        $token = preg_replace( '/\s+/', '', $token );

        if ( empty( $token ) ) wp_send_json_error( 'No token provided.' );

        $page = Yatoon_SF_API::verify_page_token( $token );
        if ( ! $page ) {
            wp_send_json_error( 'Invalid token or token has expired. Check the error log (Tools > Site Health > Info, or your host\'s PHP error log) for the exact API response.' );
        }

        // Save Facebook Page info + profile
        update_option( 'yatoon_sf_fb_page_token', $token );
        update_option( 'yatoon_sf_fb_page_id',    $page['id'] );
        update_option( 'yatoon_sf_fb_page_name',  $page['name'] );
        update_option( 'yatoon_sf_fb_sync_fail_count', 0 );

        $this->apply_fb_profile( $page );

        wp_send_json_success( array(
            'page_name' => $page['name'],
            'page_id'   => $page['id'],
        ) );
    }

    /* ---------------------------------------------------------------  Verify Instagram token AJAX */

    public function ajax_verify_ig_token() {
        check_ajax_referer( 'yatoon_sf_nonce', 'nonce' );
        if ( ! current_user_can( 'manage_options' ) ) wp_send_json_error( 'Permission denied.' );

        $token = isset( $_POST['token'] ) ? sanitize_text_field( wp_unslash( $_POST['token'] ) ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
        $token = preg_replace( '/\s+/', '', $token );

        if ( empty( $token ) ) wp_send_json_error( 'No token provided.' );

        $ig = Yatoon_SF_API::verify_ig_token( $token );
        if ( ! $ig ) {
            wp_send_json_error( 'Invalid Instagram token or token has expired.' );
        }

        update_option( 'yatoon_sf_ig_token',    $token );
        update_option( 'yatoon_sf_ig_user_id',  $ig['id'] );
        update_option( 'yatoon_sf_ig_username', isset( $ig['username'] ) ? $ig['username'] : '' );
        update_option( 'yatoon_sf_ig_display_name', isset( $ig['name'] ) ? $ig['name'] : '' );
        // Remember when this token was (re)saved so we can estimate its
        // ~60-day expiry and warn the admin proactively instead of only
        // reacting after sync already started failing.
        update_option( 'yatoon_sf_ig_token_saved_at', current_time( 'mysql' ) );
        update_option( 'yatoon_sf_ig_sync_fail_count', 0 );

        $this->apply_ig_profile( $ig );

        wp_send_json_success( array(
            'username' => isset( $ig['username'] ) ? $ig['username'] : '',
        ) );
    }

    /* ---------------------------------------------------------------  Manual sync AJAX */

    public function ajax_manual_sync() {
        check_ajax_referer( 'yatoon_sf_nonce', 'nonce' );
        if ( ! current_user_can( 'manage_options' ) ) wp_send_json_error( 'Permission denied.' );

        if ( ! $this->acquire_lock() ) {
            wp_send_json_error( 'A sync is already in progress. Please wait a few seconds and try again.' );
        }

        $platform = isset( $_POST['platform'] ) ? sanitize_text_field( wp_unslash( $_POST['platform'] ) ) : 'all';
        $results  = array();

        try {
            if ( $platform === 'all' || $platform === 'instagram' ) {
                $results['instagram'] = $this->sync_instagram();
            }
            if ( $platform === 'all' || $platform === 'facebook' ) {
                $results['facebook'] = $this->sync_facebook();
            }
        } finally {
            $this->release_lock();
        }

        wp_send_json_success( $results );
    }

    /* ---------------------------------------------------------------  Auto sync */

    public function run_auto_sync() {
        if ( ! $this->acquire_lock() ) {
            // A manual sync (or a stray overlapping cron run) is already
            // in progress; skip this run rather than racing it.
            return;
        }

        update_option( 'yatoon_sf_last_auto_sync_started', current_time( 'mysql' ) );

        try {
            $fb_page    = $this->refresh_profile();
            $ig         = $this->sync_instagram();
            $fb         = $this->sync_facebook( $fb_page );
        } finally {
            $this->release_lock();
        }

        update_option( 'yatoon_sf_last_auto_sync_finished', current_time( 'mysql' ) );
        update_option( 'yatoon_sf_last_auto_sync_result', wp_json_encode( array(
            'instagram' => $ig,
            'facebook'  => $fb,
        ) ) );
    }

    /* ---------------------------------------------------------------  Simple sync mutex */

    private function acquire_lock() {
        if ( get_transient( self::LOCK_KEY ) ) {
            return false;
        }
        // Auto-expire after 2 minutes in case a previous run died mid-way
        // (PHP fatal, host killed the request, etc.) so syncing never gets
        // permanently stuck.
        set_transient( self::LOCK_KEY, time(), 2 * MINUTE_IN_SECONDS );
        return true;
    }

    private function release_lock() {
        delete_transient( self::LOCK_KEY );
    }

    /* ---------------------------------------------------------------  Refresh profile data */

    /**
     * Refreshes cached profile info (avatar/followers/bio) for both
     * platforms. Returns the freshly-fetched Facebook page array (or null)
     * so callers such as run_auto_sync() can hand it to sync_facebook()
     * instead of triggering a second, redundant Graph API request for the
     * same data a moment later.
     */
    private function refresh_profile() {
        $token   = get_option( 'yatoon_sf_fb_page_token' );
        $page_id = get_option( 'yatoon_sf_fb_page_id' );
        $page    = null;

        // Refresh FB profile
        if ( ! empty( $token ) && ! empty( $page_id ) ) {
            $page = Yatoon_SF_API::verify_page_token( $token );
            if ( $page ) {
                $this->apply_fb_profile( $page );
            }
        }

        // Refresh IG profile via direct IG token
        $ig_token = get_option( 'yatoon_sf_ig_token' );
        if ( ! empty( $ig_token ) ) {
            $ig = Yatoon_SF_API::verify_ig_token( $ig_token );
            if ( $ig ) {
                $this->apply_ig_profile( $ig );
            }
        }

        return $page;
    }

    /**
     * Persist the subset of Facebook Page profile fields we display,
     * factored out so we only write this logic once instead of the three
     * near-identical copies that used to live in this class.
     */
    private function apply_fb_profile( $page ) {
        if ( ! is_array( $page ) ) {
            return;
        }
        if ( isset( $page['picture']['data']['url'] ) ) update_option( 'yatoon_sf_fb_avatar',    $page['picture']['data']['url'] );
        if ( isset( $page['fan_count'] ) )             update_option( 'yatoon_sf_fb_followers', Yatoon_SF_API::format_followers( $page['fan_count'] ) );
        if ( isset( $page['about'] ) )                 update_option( 'yatoon_sf_fb_bio',       $page['about'] );
        if ( isset( $page['name'] ) )                  update_option( 'yatoon_sf_fb_page_name', $page['name'] );
        if ( isset( $page['id'] ) )                    update_option( 'yatoon_sf_fb_page_id',   $page['id'] );
    }

    private function apply_ig_profile( $ig ) {
        if ( ! is_array( $ig ) ) {
            return;
        }
        if ( isset( $ig['profile_picture_url'] ) ) update_option( 'yatoon_sf_ig_avatar',    $ig['profile_picture_url'] );
        if ( isset( $ig['followers_count'] ) )     update_option( 'yatoon_sf_ig_followers', Yatoon_SF_API::format_followers( $ig['followers_count'] ) );
        if ( isset( $ig['biography'] ) )           update_option( 'yatoon_sf_ig_bio',       $ig['biography'] );
    }

    /* ---------------------------------------------------------------  Failure-streak tracking (for expiry warnings) */

    private function record_sync_result( $platform, $succeeded, $definitive = true ) {
        $key = 'facebook' === $platform ? 'yatoon_sf_fb_sync_fail_count' : 'yatoon_sf_ig_sync_fail_count';
        if ( $succeeded ) {
            update_option( $key, 0 );
            return;
        }
        // Only a confirmed, unambiguous auth failure (Yatoon_SF_API
        // ...::is_definitive_auth_failure()) should count toward the "your
        // token probably expired" streak/banner. A transient blip (rate
        // limit, momentary Meta outage, network hiccup) must never nudge
        // this counter - otherwise a run of bad luck could trigger a false
        // "go regenerate your token" warning for a token that was never
        // actually broken in the first place.
        if ( ! $definitive ) {
            return;
        }
        $count = (int) get_option( $key, 0 ) + 1;
        update_option( $key, $count );
    }

    /* ---------------------------------------------------------------  Instagram */

    public function sync_instagram() {
        $ig_token = get_option( 'yatoon_sf_ig_token' );

        if ( empty( $ig_token ) ) {
            $reason = 'No Instagram token saved.';
            update_option( 'yatoon_sf_last_error_ig', $reason );
            return array( 'status' => 'skipped', 'reason' => $reason );
        }

        $media = Yatoon_SF_API::get_ig_media_direct( $ig_token, 100 );
        if ( is_wp_error( $media ) ) {
            $definitive = Yatoon_SF_API::$last_ig_error_definitive;
            $reason = $definitive
                ? 'Instagram token is invalid or has expired. Generate a new token and re-paste it in Step 1B. (' . $media->get_error_message() . ')'
                : 'Temporary connection issue while syncing Instagram - not a token problem, this will retry automatically on the next scheduled sync. (' . $media->get_error_message() . ')';
            update_option( 'yatoon_sf_last_error_ig', $reason );
            $this->record_sync_result( 'instagram', false, $definitive );
            return array( 'status' => 'error', 'reason' => $reason );
        }
        if ( empty( $media ) ) {
            update_option( 'yatoon_sf_last_sync_ig', current_time( 'mysql' ) );
            update_option( 'yatoon_sf_last_error_ig', '' );
            $this->record_sync_result( 'instagram', true );
            return array( 'status' => 'ok', 'saved' => 0 );
        }

        $saved = 0;
        foreach ( $media as $item ) {
            // Defensive: the Graph API can occasionally omit fields on a
            // given item (malformed/partial response, unsupported media
            // sub-type, etc.) - skip anything that isn't a usable, fully
            // identified image instead of throwing PHP notices/warnings.
            if ( empty( $item['id'] ) || empty( $item['media_url'] ) ) {
                continue;
            }
            $media_type = isset( $item['media_type'] ) ? $item['media_type'] : '';
            if ( ! in_array( $media_type, array( 'IMAGE', 'CAROUSEL_ALBUM', 'VIDEO', 'REELS' ), true ) ) continue;
            $is_video = in_array( $media_type, array( 'VIDEO', 'REELS' ), true );
            // For video, only ever use a real thumbnail_url as the poster
            // image - never fall back to the video file itself, which
            // isn't renderable as an <img>.
            $thumbnail = isset( $item['thumbnail_url'] ) ? $item['thumbnail_url'] : ( $is_video ? '' : $item['media_url'] );

            $saved += $this->upsert( array(
                'platform'   => 'instagram',
                'media_id'   => 'ig_' . $item['id'],
                'media_url'  => $item['media_url'],
                'thumbnail'  => $thumbnail,
                'permalink'  => isset( $item['permalink'] )     ? $item['permalink']     : '',
                'caption'    => isset( $item['caption'] )       ? $item['caption']       : '',
                'media_type' => $media_type,
                'taken_at'   => isset( $item['timestamp'] ) ? gmdate( 'Y-m-d H:i:s', strtotime( $item['timestamp'] ) ) : current_time( 'mysql' ),
            ) );
        }

        update_option( 'yatoon_sf_last_sync_ig', current_time( 'mysql' ) );
        update_option( 'yatoon_sf_last_error_ig', '' );
        $this->record_sync_result( 'instagram', true );
        return array( 'status' => 'ok', 'saved' => $saved );
    }

    /* ---------------------------------------------------------------  Facebook */

    /**
     * @param array|null $cached_page Optional Facebook Page profile array
     *                                already fetched earlier in this same
     *                                request (e.g. by refresh_profile()
     *                                during an auto-sync run), so this
     *                                method can avoid a second identical
     *                                Graph API call for the same data.
     */
    public function sync_facebook( $cached_page = null ) {
        $page_id    = get_option( 'yatoon_sf_fb_page_id' );
        $page_token = get_option( 'yatoon_sf_fb_page_token' );

        if ( empty( $page_token ) ) {
            $reason = 'No Facebook token saved.';
            update_option( 'yatoon_sf_last_error_fb', $reason );
            return array( 'status' => 'skipped', 'reason' => $reason );
        }

        $page = is_array( $cached_page ) ? $cached_page : null;

        if ( empty( $page_id ) ) {
            if ( null === $page ) {
                $page = Yatoon_SF_API::verify_page_token( $page_token );
            }
            if ( ! $page || empty( $page['id'] ) ) {
                $definitive = Yatoon_SF_API::$last_fb_error_definitive;
                $reason = $definitive
                    ? 'Facebook Page token is invalid or has expired. Generate a new long-lived Page token in Meta, then paste it in Step 1A.'
                    : 'Temporary connection issue while reconnecting to Facebook - not a token problem, this will retry automatically on the next scheduled sync.';
                update_option( 'yatoon_sf_last_error_fb', $reason );
                $this->record_sync_result( 'facebook', false, $definitive );
                return array( 'status' => 'error', 'reason' => $reason );
            }
            $page_id = $page['id'];
            $this->apply_fb_profile( $page );
        }

        $photos = Yatoon_SF_API::get_fb_photos( $page_id, $page_token, 100 );
        if ( is_wp_error( $photos ) ) {
            $definitive = Yatoon_SF_API::$last_fb_error_definitive;
            $reason = $definitive
                ? 'Facebook Page token is invalid or has expired. Generate a new long-lived Page token in Meta, then paste it in Step 1A. (' . $photos->get_error_message() . ')'
                : 'Temporary connection issue while syncing Facebook - not a token problem, this will retry automatically on the next scheduled sync. (' . $photos->get_error_message() . ')';
            update_option( 'yatoon_sf_last_error_fb', $reason );
            $this->record_sync_result( 'facebook', false, $definitive );
            return array( 'status' => 'error', 'reason' => $reason );
        }

        // Only hit the profile endpoint again if we don't already have a
        // fresh copy from earlier in this same request.
        if ( null === $page ) {
            $page = Yatoon_SF_API::verify_page_token( $page_token );
        }
        $this->apply_fb_profile( $page );

        if ( empty( $photos ) ) {
            update_option( 'yatoon_sf_last_sync_fb', current_time( 'mysql' ) );
            update_option( 'yatoon_sf_last_error_fb', '' );
            $this->record_sync_result( 'facebook', true );
            return array( 'status' => 'ok', 'saved' => 0 );
        }

        $saved = 0;
        foreach ( $photos as $item ) {
            if ( empty( $item['id'] ) ) continue;

            $media_id = 'fb_' . $item['id'];
            $existing = $this->get_existing_photo( $media_id );
            $images = isset( $item['images'] ) ? $item['images'] : array();
            $media_url = '';
            $thumbnail = '';

            if ( ! empty( $images ) && is_array( $images ) ) {
                usort( $images, function ( $a, $b ) {
                    $a_size = isset( $a['width'], $a['height'] ) ? (int) $a['width'] * (int) $a['height'] : 0;
                    $b_size = isset( $b['width'], $b['height'] ) ? (int) $b['width'] * (int) $b['height'] : 0;
                    return $b_size - $a_size;
                } );

                foreach ( $images as $image ) {
                    if ( ! empty( $image['source'] ) ) {
                        if ( empty( $media_url ) ) {
                            $media_url = $image['source'];
                        }
                        $thumbnail = $image['source'];
                    }
                }
            }

            if ( empty( $media_url ) && ! empty( $item['picture'] ) ) {
                $media_url = $item['picture'];
                $thumbnail = $item['picture'];
            }

            if ( empty( $media_url ) ) continue;

            $existing_media_url = isset( $existing->media_url ) ? $existing->media_url : '';
            $existing_thumbnail = isset( $existing->thumbnail ) ? $existing->thumbnail : '';
            $media_url = $this->cache_remote_image( $media_url, 'facebook', $media_id, $existing_media_url );
            $thumbnail = $this->cache_remote_image( $thumbnail, 'facebook', $media_id . '-thumb', $existing_thumbnail );
            if ( empty( $media_url ) ) {
                continue;
            }

            $saved += $this->upsert( array(
                'platform'   => 'facebook',
                'media_id'   => $media_id,
                'media_url'  => $media_url,
                'thumbnail'  => $thumbnail,
                'permalink'  => isset( $item['link'] )  ? $item['link']  : '',
                'caption'    => isset( $item['name'] )  ? $item['name']  : '',
                'media_type' => 'IMAGE',
                'taken_at'   => isset( $item['created_time'] ) ? gmdate( 'Y-m-d H:i:s', strtotime( $item['created_time'] ) ) : current_time( 'mysql' ),
            ) );
        }

        $videos = Yatoon_SF_API::get_fb_videos( $page_id, $page_token, 25 );
        if ( is_wp_error( $videos ) ) {
            $this->debug_log( 'Facebook video sync skipped: ' . $videos->get_error_message() );
        } else {
            foreach ( $videos as $item ) {
                if ( empty( $item['id'] ) || empty( $item['source'] ) ) {
                    continue;
                }
                $media_id = 'fb_video_' . $item['id'];
                $existing = $this->get_existing_photo( $media_id );
                $existing_thumbnail = isset( $existing->thumbnail ) ? $existing->thumbnail : '';
                // Only the poster/cover image is cached locally - the
                // video file itself stays a direct link to Meta's CDN
                // (see get_fb_videos() for why).
                $thumbnail = ! empty( $item['picture'] )
                    ? $this->cache_remote_image( $item['picture'], 'facebook', $media_id . '-thumb', $existing_thumbnail )
                    : $existing_thumbnail;

                $saved += $this->upsert( array(
                    'platform'   => 'facebook',
                    'media_id'   => $media_id,
                    'media_url'  => $item['source'],
                    'thumbnail'  => $thumbnail,
                    'permalink'  => isset( $item['permalink_url'] ) ? $item['permalink_url'] : '',
                    'caption'    => isset( $item['description'] )   ? $item['description']   : '',
                    'media_type' => 'VIDEO',
                    'taken_at'   => isset( $item['created_time'] ) ? gmdate( 'Y-m-d H:i:s', strtotime( $item['created_time'] ) ) : current_time( 'mysql' ),
                ) );
            }
        }

        $feed = Yatoon_SF_API::get_fb_feed_text_posts( $page_id, $page_token, 50 );
        if ( is_wp_error( $feed ) ) {
            $this->debug_log( 'Facebook text feed sync skipped: ' . $feed->get_error_message() );
        } else {
            foreach ( $feed as $item ) {
                if ( empty( $item['id'] ) || empty( $item['message'] ) || ! empty( $item['full_picture'] ) ) {
                    continue;
                }
                $saved += $this->upsert( array(
                    'platform'   => 'facebook',
                    'media_id'   => 'fb_text_' . $item['id'],
                    'media_url'  => '',
                    'thumbnail'  => '',
                    'permalink'  => isset( $item['permalink_url'] ) ? $item['permalink_url'] : '',
                    'caption'    => $item['message'],
                    'media_type' => 'TEXT',
                    'taken_at'   => isset( $item['created_time'] ) ? gmdate( 'Y-m-d H:i:s', strtotime( $item['created_time'] ) ) : current_time( 'mysql' ),
                ) );
            }
        }

        update_option( 'yatoon_sf_last_sync_fb', current_time( 'mysql' ) );
        update_option( 'yatoon_sf_last_error_fb', '' );
        $this->record_sync_result( 'facebook', true );
        return array( 'status' => 'ok', 'saved' => $saved );
    }

    /* ---------------------------------------------------------------  DB upsert */

    private function get_existing_photo( $media_id ) {
        global $wpdb;
        $table = $wpdb->prefix . 'yatoon_sf_photos';
        // phpcs:disable WordPress.DB.DirectDatabaseQuery
        return $wpdb->get_row( $wpdb->prepare( "SELECT * FROM " . esc_sql( $table ) . " WHERE media_id = %s", $media_id ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
        // phpcs:enable
    }

    private function cache_remote_image( $remote_url, $platform, $media_id, $existing_url = '' ) {
        $remote_url = trim( (string) $remote_url );
        if ( empty( $remote_url ) ) {
            return $existing_url;
        }

        if ( $this->is_cached_upload_url( $remote_url ) ) {
            return $remote_url;
        }

        if ( $this->cached_upload_file_exists( $existing_url ) ) {
            return $existing_url;
        }

        $upload = wp_upload_dir();
        if ( ! empty( $upload['error'] ) ) {
            $this->debug_log( 'cache_remote_image upload dir error: ' . $upload['error'] );
            return $existing_url ? $existing_url : $remote_url;
        }

        $platform = sanitize_key( $platform );
        $dir = trailingslashit( $upload['basedir'] ) . 'yatoon-social-feed/' . $platform;
        $url_base = trailingslashit( $upload['baseurl'] ) . 'yatoon-social-feed/' . $platform;

        if ( ! wp_mkdir_p( $dir ) ) {
            $this->debug_log( 'cache_remote_image could not create cache directory.' );
            return $existing_url ? $existing_url : $remote_url;
        }

        $response = wp_safe_remote_get( $remote_url, array(
            'timeout'     => 30,
            'redirection' => 5,
            'sslverify'   => true,
        ) );

        if ( is_wp_error( $response ) ) {
            $this->debug_log( 'cache_remote_image request error: ' . $response->get_error_message() );
            return $existing_url ? $existing_url : $remote_url;
        }

        $code = wp_remote_retrieve_response_code( $response );
        $body = wp_remote_retrieve_body( $response );
        if ( 200 !== (int) $code || empty( $body ) ) {
            $this->debug_log( 'cache_remote_image failed with HTTP ' . (int) $code );
            return $existing_url ? $existing_url : $remote_url;
        }

        $content_type = wp_remote_retrieve_header( $response, 'content-type' );
        if ( is_array( $content_type ) ) {
            $content_type = reset( $content_type );
        }
        $extension = $this->image_extension_from_content_type( $content_type );
        if ( empty( $extension ) ) {
            $path = wp_parse_url( $remote_url, PHP_URL_PATH );
            $extension = pathinfo( (string) $path, PATHINFO_EXTENSION );
            $extension = preg_replace( '/[^a-zA-Z0-9]/', '', strtolower( $extension ) );
        }
        if ( ! in_array( $extension, array( 'jpg', 'jpeg', 'png', 'gif', 'webp' ), true ) ) {
            $extension = 'jpg';
        }

        $filename = sanitize_file_name( $media_id . '-' . substr( md5( $remote_url ), 0, 10 ) . '.' . $extension );
        $filepath = trailingslashit( $dir ) . $filename;

        if ( ! file_exists( $filepath ) ) {
            $written = file_put_contents( $filepath, $body ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
            if ( false === $written ) {
                $this->debug_log( 'cache_remote_image could not write cached file.' );
                return $existing_url ? $existing_url : $remote_url;
            }
        }

        return trailingslashit( $url_base ) . $filename;
    }

    private function cached_upload_file_exists( $url ) {
        if ( ! $this->is_cached_upload_url( $url ) ) {
            return false;
        }

        $upload = wp_upload_dir();
        $relative = ltrim( str_replace( $upload['baseurl'], '', $url ), '/\\' );
        $path = trailingslashit( $upload['basedir'] ) . str_replace( array( '/', '\\' ), DIRECTORY_SEPARATOR, $relative );
        return file_exists( $path );
    }

    private function is_cached_upload_url( $url ) {
        if ( empty( $url ) ) {
            return false;
        }

        $upload = wp_upload_dir();
        return ! empty( $upload['baseurl'] ) && strpos( $url, trailingslashit( $upload['baseurl'] ) . 'yatoon-social-feed/' ) === 0;
    }

    private function image_extension_from_content_type( $content_type ) {
        $content_type = strtolower( trim( (string) $content_type ) );
        if ( strpos( $content_type, 'image/jpeg' ) !== false || strpos( $content_type, 'image/jpg' ) !== false ) return 'jpg';
        if ( strpos( $content_type, 'image/png' ) !== false ) return 'png';
        if ( strpos( $content_type, 'image/gif' ) !== false ) return 'gif';
        if ( strpos( $content_type, 'image/webp' ) !== false ) return 'webp';
        return '';
    }

    private function debug_log( $message ) {
        if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
            error_log( 'Yatoon Social Feed: ' . $message ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
        }
    }

    private function upsert( $data ) {
        global $wpdb;
        $table = $wpdb->prefix . 'yatoon_sf_photos';
        $data['synced_at'] = current_time( 'mysql' );
        // phpcs:disable WordPress.DB.DirectDatabaseQuery
        $exists = $wpdb->get_var( $wpdb->prepare( "SELECT id FROM " . esc_sql( $table ) . " WHERE media_id = %s", $data['media_id'] ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
        if ( $exists ) { $wpdb->update( $table, $data, array( 'id' => $exists ) ); return 0; }
        $wpdb->insert( $table, $data ); return 1;
        // phpcs:enable
    }
}
