<?php
declare(strict_types=1);
// ==================================================
// Nuclear Fancy Pants Edition™ — NUCLEAR+++
// File: /home/ploi/automarketer-ai.com/public/wp-content/plugins/automarketer-ai/includes/class-posting-engine.php
// Description: Layer 5 — Posting Engine. Posts approved Facebook and Instagram posts via Graph API on hourly cron - NUCLEAR+++
// Last Edited: 2026-04-26
// ==================================================

namespace AutoMarketerAI;

// No direct access
defined('ABSPATH') || exit;

final class PostingEngine
{
    // WP cron hook name — referenced in automarketer-ai.php for registration
    public const CRON_HOOK = 'amai_posting_engine_run';

    // Graph API base
    private const GRAPH_BASE = 'https://graph.facebook.com/v19.0';
    private const TIKTOK_PUBLISH_URL = 'https://open.tiktokapis.com/v2/post/publish/content/init/';

    // Maximum age in seconds of an approved post we will still attempt to post.
    // Posts older than this are considered missed and marked failed.
    // 23 hours gives a full day's worth of catch-up without risking a post
    // going out a day late (which could make scheduled content look stale).
    private const MAX_CATCH_UP_SECONDS = 82800; // 23 hours

    // --------------------------------------------------
    // Init — registers cron hook callback
    // --------------------------------------------------
    public static function init(): void
    {
        add_action(self::CRON_HOOK, [self::class, 'run']);
    }

    // --------------------------------------------------
    // Schedule / clear helpers — called from activation
    // and deactivation hooks in automarketer-ai.php
    // --------------------------------------------------
    public static function scheduleHourlyRun(): void
    {
        if (!wp_next_scheduled(self::CRON_HOOK)) {
            wp_schedule_event(time(), 'hourly', self::CRON_HOOK);
        }
    }

    public static function clearScheduledRun(): void
    {
        $timestamp = wp_next_scheduled(self::CRON_HOOK);
        if ($timestamp !== false) {
            wp_unschedule_event($timestamp, self::CRON_HOOK);
        }
    }

    // --------------------------------------------------
    // Main cron entry point — Facebook loop first, then Instagram
    // --------------------------------------------------
    public static function run(): void
    {
        global $wpdb;

        $table      = $wpdb->prefix . 'amai_scheduled_posts';
        $now_utc    = gmdate('Y-m-d H:i:s');
        $cutoff_utc = gmdate('Y-m-d H:i:s', time() - self::MAX_CATCH_UP_SECONDS);

        // --------------------------------------------------
        // Facebook loop
        // --------------------------------------------------
        $fb_posts = $wpdb->get_results(
            $wpdb->prepare(
                "SELECT id, facebook_page_id, post_content, scheduled_utc,
                        post_image_url, unsplash_attachment_id, location_place_id, feeling
                 FROM {$table}
                 WHERE status        = 'approved'
                   AND channel       = 'facebook'
                   AND scheduled_utc IS NOT NULL
                   AND scheduled_utc <= %s
                   AND scheduled_utc >= %s
                   AND generation_batch NOT LIKE 'manual%'
                 ORDER BY scheduled_utc ASC",
                $now_utc,
                $cutoff_utc
            ),
            ARRAY_A
        );

        if (!empty($fb_posts)) {
            $fb_token = self::getPageToken();
            if ($fb_token !== null) {
                foreach ($fb_posts as $post) {
                    self::publishFacebookPost(
                        (int)    $post['id'],
                        (string) $post['facebook_page_id'],
                        (string) $post['post_content'],
                        $fb_token,
                        (string) ($post['post_image_url']         ?? ''),
                        (int)    ($post['unsplash_attachment_id'] ?? 0),
                        (string) ($post['location_place_id']      ?? ''),
                        (string) ($post['feeling']                ?? '')
                    );
                }
            }
        }

        // --------------------------------------------------
        // Instagram loop
        // --------------------------------------------------
        $ig_posts = $wpdb->get_results(
            $wpdb->prepare(
                "SELECT id, post_content, scheduled_utc,
                        post_image_url, unsplash_attachment_id
                 FROM {$table}
                 WHERE status        = 'approved'
                   AND channel       = 'instagram'
                   AND scheduled_utc IS NOT NULL
                   AND scheduled_utc <= %s
                   AND scheduled_utc >= %s
                   AND generation_batch NOT LIKE 'manual%'
                 ORDER BY scheduled_utc ASC",
                $now_utc,
                $cutoff_utc
            ),
            ARRAY_A
        );

        if (!empty($ig_posts)) {
            $ig_account_id = FacebookAuth::getInstagramAccountId();
            $ig_token      = self::getPageToken();

            if ($ig_account_id !== null && $ig_token !== null) {
                foreach ($ig_posts as $post) {
                    self::publishInstagramPost(
                        (int)    $post['id'],
                        $ig_account_id,
                        (string) $post['post_content'],
                        $ig_token,
                        (string) ($post['post_image_url']         ?? ''),
                        (int)    ($post['unsplash_attachment_id'] ?? 0)
                    );
                }
            }
        }

        // --------------------------------------------------
        // TikTok loop
        // --------------------------------------------------
        $tk_posts = $wpdb->get_results(
            $wpdb->prepare(
                "SELECT id, post_content, scheduled_utc,
                        post_image_url, unsplash_attachment_id
                 FROM {$table}
                 WHERE status        = 'approved'
                   AND channel       = 'tiktok'
                   AND scheduled_utc IS NOT NULL
                   AND scheduled_utc <= %s
                   AND scheduled_utc >= %s
                   AND generation_batch NOT LIKE 'manual%'
                 ORDER BY scheduled_utc ASC",
                $now_utc,
                $cutoff_utc
            ),
            ARRAY_A
        );

        if (!empty($tk_posts)) {
            $tk_token = TikTokAuth::getAccessToken();

            if ($tk_token !== null) {
                foreach ($tk_posts as $post) {
                    self::publishTiktokPost(
                        (int)    $post['id'],
                        (string) $post['post_content'],
                        $tk_token,
                        (string) ($post['post_image_url'] ?? ''),
                        (int)    ($post['unsplash_attachment_id'] ?? 0)
                    );
                }
            }
        }

        // --------------------------------------------------
        // X loop
        // --------------------------------------------------
        $x_posts = $wpdb->get_results(
            $wpdb->prepare(
                "SELECT id, post_content, scheduled_utc,
                        unsplash_attachment_id, post_image_url
                 FROM {$table}
                 WHERE status        = 'approved'
                   AND channel       = 'x'
                   AND scheduled_utc IS NOT NULL
                   AND scheduled_utc <= %s
                   AND scheduled_utc >= %s
                   AND generation_batch NOT LIKE 'manual%'
                 ORDER BY scheduled_utc ASC",
                $now_utc,
                $cutoff_utc
            ),
            ARRAY_A
        );

        if (!empty($x_posts)) {
            $x_token = XAuth::getAccessToken();

            if ($x_token !== null) {
                foreach ($x_posts as $post) {
                    self::publishXPost(
                        (int)    $post['id'],
                        (string) $post['post_content'],
                        $x_token,
                        (int)    ($post['unsplash_attachment_id'] ?? 0),
                        (string) ($post['post_image_url']         ?? '')
                    );
                }
            }
        }

        // --------------------------------------------------
        // Google Business Profile loop
        // --------------------------------------------------
        $google_posts = $wpdb->get_results(
            $wpdb->prepare(
                "SELECT id, post_content, scheduled_utc, post_image_url, unsplash_attachment_id FROM `{$wpdb->prefix}amai_scheduled_posts` WHERE status = 'approved' AND channel = 'google' AND scheduled_utc IS NOT NULL AND scheduled_utc <= %s AND scheduled_utc >= %s AND generation_batch NOT LIKE 'manual%%' ORDER BY scheduled_utc ASC",
                $now_utc,
                $cutoff_utc
            ),
            ARRAY_A
        );
        if (!empty($google_posts)) {
            $google_token = GoogleAuth::getAccessToken();
            if ($google_token !== null) {
                $google_location = GoogleAuth::getLocationId();
                foreach ($google_posts as $post) {
                    self::publishGooglePost(
                        (int)    $post['id'],
                        (string) $post['post_content'],
                        $google_token,
                        $google_location ?? '',
                        (string) ($post['post_image_url']         ?? ''),
                        (int)    ($post['unsplash_attachment_id'] ?? 0)
                    );
                }
            }
        }
    }

    // --------------------------------------------------
    // Post Now — manually publish a single failed post.
    // Only permitted for posts whose scheduled_date falls
    // within the current calendar week (Mon-Fri UTC).
    // Returns ['ok' => true] or ['ok' => false, 'message' => '...'].
    // --------------------------------------------------
    public static function postNow(int $post_id): array
    {
        global $wpdb;

        $table = $wpdb->prefix . 'amai_scheduled_posts';

        $post = $wpdb->get_row(
            $wpdb->prepare(
                "SELECT id, channel, facebook_page_id, post_content, scheduled_date, status,
                        post_image_url, unsplash_attachment_id, location_place_id, feeling
                 FROM {$table}
                 WHERE id = %d LIMIT 1",
                $post_id
            ),
            ARRAY_A
        );

        if ($post === null) {
            return ['ok' => false, 'message' => 'Post not found.'];
        }

        if ($post['status'] !== 'failed') {
            return ['ok' => false, 'message' => 'Only failed posts can be posted manually.'];
        }

        // Enforce current-week guard — scheduled_date must fall within
        // the current Mon-Fri week (UTC dates).
        $today     = new \DateTimeImmutable('today', new \DateTimeZone('UTC'));
        $dow       = (int) $today->format('N'); // 1=Mon ... 7=Sun
        $week_mon  = $today->modify('-' . ($dow - 1) . ' days');
        $week_fri  = $week_mon->modify('+4 days');

        try {
            $sched = new \DateTimeImmutable((string) $post['scheduled_date'], new \DateTimeZone('UTC'));
        } catch (\Throwable) {
            return ['ok' => false, 'message' => 'Invalid scheduled date on post.'];
        }

        $sched_str    = $sched->format('Y-m-d');
        $week_mon_str = $week_mon->format('Y-m-d');
        $week_fri_str = $week_fri->format('Y-m-d');

        if ($sched_str < $week_mon_str || $sched_str > $week_fri_str) {
            return ['ok' => false, 'message' => 'This post is from a previous week and can no longer be posted manually.'];
        }

        $channel = (string) ($post['channel'] ?? 'facebook');

        if ($channel === 'instagram') {
            $token = self::getPageToken();
            if ($token === null) {
                return ['ok' => false, 'message' => 'No active Facebook connection.'];
            }
            $ig_account_id = FacebookAuth::getInstagramAccountId();
            if ($ig_account_id === null) {
                return ['ok' => false, 'message' => 'No active Instagram connection.'];
            }
            self::publishInstagramPost(
                (int)    $post['id'],
                $ig_account_id,
                (string) $post['post_content'],
                $token,
                (string) ($post['post_image_url']         ?? ''),
                (int)    ($post['unsplash_attachment_id'] ?? 0)
            );
        } elseif ($channel === 'x') {
            $x_token = XAuth::getAccessToken();
            if ($x_token === null) {
                self::markFailed($post_id, 'no_active_x_connection_manual', 'x');
            } else {
                self::publishXPost(
                    (int)    $post['id'],
                    (string) $post['post_content'],
                    $x_token,
                    (int)    ($post['unsplash_attachment_id'] ?? 0),
                    (string) ($post['post_image_url']         ?? '')
                );
            }
        } else {
            $token = self::getPageToken();
            if ($token === null) {
                return ['ok' => false, 'message' => 'No active Facebook connection.'];
            }
            self::publishFacebookPost(
                (int)    $post['id'],
                (string) $post['facebook_page_id'],
                (string) $post['post_content'],
                $token,
                (string) ($post['post_image_url']         ?? ''),
                (int)    ($post['unsplash_attachment_id'] ?? 0),
                (string) ($post['location_place_id']      ?? ''),
                (string) ($post['feeling']                ?? '')
            );
        }

        $updated = $wpdb->get_var(
            $wpdb->prepare(
                "SELECT status FROM {$table} WHERE id = %d LIMIT 1",
                $post_id
            )
        );

        if ($updated === 'posted') {
            return ['ok' => true];
        }

        $reason = (string) $wpdb->get_var(
            $wpdb->prepare(
                "SELECT failure_reason FROM {$table} WHERE id = %d LIMIT 1",
                $post_id
            )
        );

        return ['ok' => false, 'message' => $reason !== '' ? $reason : 'The post was rejected. Please try again.'];
    }

    // --------------------------------------------------
    // Manual Post — compose and publish immediately.
    // Inserts a row with batch "manual", calls the appropriate
    // publish method, then returns ok/fail based on resulting status.
    // Returns ['ok' => true, 'post_id' => int]
    //      or ['ok' => false, 'message' => string].
    // --------------------------------------------------
    public static function postManual(
        string $content,
        string $feeling       = '',
        int    $attachment_id = 0,
        string $image_url     = '',
        string $post_type     = 'other',
        string $channel       = 'facebook'
    ): array {
        if (trim($content) === '') {
            return ['ok' => false, 'message' => 'Post content is required.'];
        }

        // Instagram requires an image — fail early with a clear message
        if ($channel === 'instagram') {
            $has_image = $attachment_id > 0 || $image_url !== '';
            if (!$has_image) {
                return ['ok' => false, 'message' => 'Instagram posts require an image.'];
            }
        }

        $token         = null;
        $connection    = null;
        $page_id       = '';
        $ig_account_id = null;
        if ($channel === 'facebook' || $channel === 'instagram') {
            $token = self::getPageToken();
            if ($token === null) {
                return ['ok' => false, 'message' => 'No active Facebook connection.'];
            }
            $connection = FacebookAuth::getConnection();
            if ($connection === null) {
                return ['ok' => false, 'message' => 'No active Facebook connection.'];
            }
            if ($channel === 'instagram') {
                $ig_account_id = FacebookAuth::getInstagramAccountId();
                if ($ig_account_id === null) {
                    return ['ok' => false, 'message' => 'No active Instagram connection.'];
                }
            }
            $page_id = (string) ($connection['page_id'] ?? '');
        }
        $tenant_id = 'default';
        $now_date  = gmdate('Y-m-d');
        $now_time  = gmdate('H:i:s');
        $now_utc   = gmdate('Y-m-d H:i:s');

        global $wpdb;
        $table = $wpdb->prefix . 'amai_scheduled_posts';

        // Resolve effective image URL — attachment takes priority
        $effective_image_url = $image_url;
        if ($attachment_id > 0) {
            $att_url = (string) wp_get_attachment_url($attachment_id);
            if ($att_url !== '') {
                $effective_image_url = $att_url;
            }
        }

        // Insert the row first so we have a post_id for markPosted/markFailed
        $inserted = $wpdb->query(
            $wpdb->prepare(
                "INSERT INTO {$table}
                 (tenant_id, channel, facebook_page_id, post_type, post_content,
                  post_image_url, feeling, unsplash_attachment_id,
                  generation_batch, scheduled_date, scheduled_time, scheduled_utc,
                  status, created_at)
                 VALUES (%s, %s, %s, %s, %s, %s, %s, %d, %s, %s, %s, %s, %s, %s)",
                $tenant_id,
                $channel,
                $page_id,
                $post_type,
                $content,
                $effective_image_url !== '' ? $effective_image_url : null,
                $feeling !== '' ? $feeling : null,
                $attachment_id > 0 ? $attachment_id : 0,
                'manual-' . substr((string) time(), -8),
                $now_date,
                $now_time,
                $now_utc,
                'approved',
                $now_utc
            )
        );

        if ($inserted === false) {
            return ['ok' => false, 'message' => 'Failed to save post record.'];
        }

        $post_id = (int) $wpdb->insert_id;

        if ($channel === 'instagram') {
            self::publishInstagramPost(
                $post_id,
                $ig_account_id,
                $content,
                $token,
                $effective_image_url,
                $attachment_id
            );
        } elseif ($channel === 'x') {
            $x_token = XAuth::getAccessToken();
            if ($x_token === null) {
                self::markFailed($post_id, 'no_active_x_connection_manual', 'x');
            } else {
                self::publishXPost(
                    $post_id,
                    $content,
                    $x_token,
                    $attachment_id,
                    $effective_image_url
                );
            }
        } elseif ($channel === 'tiktok') {
            $tk_token = TikTokAuth::getAccessToken();
            if ($tk_token === null) {
                self::markFailed($post_id, 'no_active_tiktok_connection_manual', 'tiktok');
            } else {
                self::publishTiktokPost(
                    $post_id,
                    $content,
                    $tk_token,
                    $effective_image_url,
                    $attachment_id
                );
            }
        } else {
            self::publishFacebookPost(
                $post_id,
                $page_id,
                $content,
                $token,
                $effective_image_url,
                $attachment_id,
                '',
                $feeling
            );
        }

        $status = (string) $wpdb->get_var(
            $wpdb->prepare(
                "SELECT status FROM {$table} WHERE id = %d LIMIT 1",
                $post_id
            )
        );

        if ($status === 'posted') {
            return ['ok' => true, 'post_id' => $post_id];
        }

        $reason = (string) $wpdb->get_var(
            $wpdb->prepare(
                "SELECT failure_reason FROM {$table} WHERE id = %d LIMIT 1",
                $post_id
            )
        );

        return ['ok' => false, 'message' => $reason !== '' ? $reason : 'The post was rejected. Please try again.'];
    }

    // --------------------------------------------------
    // Back-fill scheduled_utc for a newly saved post.
    // --------------------------------------------------
    public static function computeAndStoreUtc(int $post_id): bool
    {
        global $wpdb;

        $table = $wpdb->prefix . 'amai_scheduled_posts';
        $row   = $wpdb->get_row(
            $wpdb->prepare(
                "SELECT scheduled_date, scheduled_time, tenant_id FROM {$table} WHERE id = %d",
                $post_id
            ),
            ARRAY_A
        );

        if ($row === null) {
            return false;
        }

        $utc = self::toUtc(
            (string) $row['scheduled_date'],
            (string) $row['scheduled_time']
        );

        if ($utc === null) {
            return false;
        }

        $wpdb->query(
            $wpdb->prepare(
                "UPDATE {$table} SET scheduled_utc = %s WHERE id = %d",
                $utc,
                $post_id
            )
        );

        return true;
    }

    // --------------------------------------------------
    // Convert a local date + time to UTC using the
    // timezone stored in the business profile.
    // --------------------------------------------------
    public static function toUtc(string $date, string $time): ?string
    {
        $tz_string = self::getClientTimezone();

        try {
            $tz  = new \DateTimeZone($tz_string);
            $utc = new \DateTimeZone('UTC');
            $dt  = new \DateTimeImmutable("{$date} {$time}", $tz);
            return $dt->setTimezone($utc)->format('Y-m-d H:i:s');
        } catch (\Throwable) {
            return null;
        }
    }

    // --------------------------------------------------
    // Publish a single Facebook post via Graph API.
    // If an image URL is provided, it is uploaded to the
    // page's photo endpoint first and the returned photo ID
    // is attached to the feed post.
    // --------------------------------------------------
    private static function publishFacebookPost(
        int    $post_id,
        string $page_id,
        string $content,
        string $token,
        string $image_url      = '',
        int    $unsplash_id    = 0,
        string $place_id       = '',
        string $feeling        = ''
    ): void {
        // --------------------------------------------------
        // Step 1 — upload photo if present, get photo ID
        // --------------------------------------------------
        $attached_media = [];
        // Prefer manually uploaded image; fall back to accepted Unsplash image
        $effective_image_url = $image_url;
        if ($effective_image_url === '' && $unsplash_id > 0) {
            $unsplash_src = (string) get_post_meta($unsplash_id, '_amai_unsplash_source_url', true);
            $effective_image_url = $unsplash_src !== '' ? $unsplash_src : (string) wp_get_attachment_url($unsplash_id);
        }
        if ($effective_image_url !== '') {
            $photo_id = self::uploadPhotoToPage($page_id, $effective_image_url, $token);
            if ($photo_id !== null) {
                // Attach as unpublished photo so it appears inline in the post
                $attached_media[] = ['media_fbid' => $photo_id];
            }
        }

        // --------------------------------------------------
        // Step 2 — build feed post params
        // --------------------------------------------------
        $params = [
            'message'      => $content,
            'access_token' => $token,
        ];

        if (!empty($attached_media)) {
            $params['attached_media'] = wp_json_encode($attached_media);
        }

        if ($place_id !== '') {
            $params['place'] = $place_id;
        }

        if ($feeling !== '') {
            $params['feeling'] = $feeling;
        }

        // --------------------------------------------------
        // Step 3 — post to /page-id/feed
        // --------------------------------------------------
        $url  = self::GRAPH_BASE . '/' . $page_id . '/feed';
        $body = http_build_query($params);

        $response = wp_remote_post($url, [
            'body'    => $body,
            'timeout' => 30,
            'headers' => [
                'Content-Type' => 'application/x-www-form-urlencoded',
            ],
        ]);

        if (is_wp_error($response)) {
            self::markFailed($post_id, 'http_error: ' . $response->get_error_message(), 'facebook');
            return;
        }

        $code = (int) wp_remote_retrieve_response_code($response);
        $raw  = wp_remote_retrieve_body($response);
        $json = json_decode($raw, true);

        if ($code !== 200 || !is_array($json) || empty($json['id'])) {
            $reason = 'graph_error';
            if (is_array($json) && isset($json['error']['message'])) {
                $reason = substr((string) $json['error']['message'], 0, 191);
            }
            self::markFailed($post_id, $reason, 'facebook');
            return;
        }

        self::markPosted($post_id, (string) $json['id'], 'facebook');
    }

    // --------------------------------------------------
    // Publish a single Instagram post via Graph API.
    // Two-step: create media container, then publish it.
    // Requires a publicly accessible image URL — posts
    // without an image are marked skipped, not failed,
    // so they do not trigger failure emails.
    // --------------------------------------------------
    private static function publishInstagramPost(
        int    $post_id,
        string $ig_account_id,
        string $content,
        string $token,
        string $image_url   = '',
        int    $unsplash_id = 0
    ): void {
        // Resolve effective image URL
        $effective_image_url = $image_url;
        if ($effective_image_url === '' && $unsplash_id > 0) {
            $source = get_post_meta($unsplash_id, '_amai_unsplash_source_url', true);
            $effective_image_url = ($source !== '' && $source !== false) ? (string) $source : (string) wp_get_attachment_url($unsplash_id);
        }

        // Instagram requires an image — try active image provider before skipping
        if ($effective_image_url === '') {
            $generated = ImageProvider::resolveForPost($content);
            if ($generated !== null) {
                $effective_image_url = $generated;
            }
        }
        if ($effective_image_url === '') {
            self::markSkipped($post_id, 'no_image');
            return;
        }

        // --------------------------------------------------
        // Step 1 — create media container
        // --------------------------------------------------
        $container_url  = self::GRAPH_BASE . '/' . $ig_account_id . '/media';
        $container_body = http_build_query([
            'image_url'    => $effective_image_url,
            'caption'      => $content,
            'access_token' => $token,
        ]);

        $container_response = wp_remote_post($container_url, [
            'body'    => $container_body,
            'timeout' => 30,
            'headers' => [
                'Content-Type' => 'application/x-www-form-urlencoded',
            ],
        ]);

        if (is_wp_error($container_response)) {
            self::markFailed($post_id, 'ig_http_error: ' . $container_response->get_error_message(), 'instagram');
            return;
        }

        $container_code = (int) wp_remote_retrieve_response_code($container_response);
        $container_raw  = wp_remote_retrieve_body($container_response);
        $container_json = json_decode($container_raw, true);

        if ($container_code !== 200 || !is_array($container_json) || empty($container_json['id'])) {
            $reason = 'ig_container_error';
            if (is_array($container_json) && isset($container_json['error']['message'])) {
                $reason = substr((string) $container_json['error']['message'], 0, 191);
            }
            self::markFailed($post_id, $reason, 'instagram');
            return;
        }

        $creation_id = (string) $container_json['id'];

        // --------------------------------------------------
        // Step 2 — poll until container status is FINISHED
        // Meta processes the image asynchronously after container
        // creation. Publishing before FINISHED returns "Media ID
        // is not available". Poll up to 10 times, 3 s apart.
        // --------------------------------------------------
        $poll_url      = self::GRAPH_BASE . '/' . $creation_id . '?fields=status_code&access_token=' . rawurlencode($token);
        $poll_ready    = false;
        $poll_attempts = 0;

        while ($poll_attempts < 10) {
            $poll_attempts++;
            sleep(3);

            $poll_response = wp_remote_get($poll_url, ['timeout' => 15]);

            if (is_wp_error($poll_response)) {
                break;
            }

            $poll_json = json_decode(wp_remote_retrieve_body($poll_response), true);

            if (is_array($poll_json) && isset($poll_json['status_code'])) {
                if ($poll_json['status_code'] === 'FINISHED') {
                    $poll_ready = true;
                    break;
                }
                if ($poll_json['status_code'] === 'ERROR') {
                    $error_msg = isset($poll_json['error']['message'])
                        ? substr((string) $poll_json['error']['message'], 0, 191)
                        : 'ig_container_processing_error';
                    self::markFailed($post_id, $error_msg, 'instagram');
                    return;
                }
                // IN_PROGRESS — keep polling
            }
        }

        if (!$poll_ready) {
            self::markFailed($post_id, 'ig_container_not_ready_after_30s', 'instagram');
            return;
        }

        // --------------------------------------------------
        // Step 3 — publish the container
        // --------------------------------------------------
        $publish_url  = self::GRAPH_BASE . '/' . $ig_account_id . '/media_publish';
        $publish_body = http_build_query([
            'creation_id'  => $creation_id,
            'access_token' => $token,
        ]);

        $publish_response = wp_remote_post($publish_url, [
            'body'    => $publish_body,
            'timeout' => 30,
            'headers' => [
                'Content-Type' => 'application/x-www-form-urlencoded',
            ],
        ]);

        if (is_wp_error($publish_response)) {
            self::markFailed($post_id, 'ig_publish_http_error: ' . $publish_response->get_error_message(), 'instagram');
            return;
        }

        $publish_code = (int) wp_remote_retrieve_response_code($publish_response);
        $publish_raw  = wp_remote_retrieve_body($publish_response);
        $publish_json = json_decode($publish_raw, true);

        if ($publish_code !== 200 || !is_array($publish_json) || empty($publish_json['id'])) {
            $reason = 'ig_publish_error';
            if (is_array($publish_json) && isset($publish_json['error']['message'])) {
                $reason = substr((string) $publish_json['error']['message'], 0, 191);
            }
            self::markFailed($post_id, $reason, 'instagram');
            return;
        }

        self::markPosted($post_id, (string) $publish_json['id'], 'instagram');
    }

    // --------------------------------------------------
    // Upload a photo to the page's /photos endpoint as
    // unpublished (published=false) so it can be attached
    // to a Facebook feed post.
    // Returns the photo ID or null on failure.
    // --------------------------------------------------
    private static function uploadPhotoToPage(
        string $page_id,
        string $image_url,
        string $token
    ): ?string {
        $url  = self::GRAPH_BASE . '/' . $page_id . '/photos';
        $body = http_build_query([
            'url'          => $image_url,
            'published'    => 'false',
            'access_token' => $token,
        ]);

        $response = wp_remote_post($url, [
            'body'    => $body,
            'timeout' => 30,
            'headers' => [
                'Content-Type' => 'application/x-www-form-urlencoded',
            ],
        ]);

        if (is_wp_error($response)) {
            return null;
        }

        $code = (int) wp_remote_retrieve_response_code($response);
        $raw  = wp_remote_retrieve_body($response);
        $json = json_decode($raw, true);

        if ($code !== 200 || !is_array($json) || empty($json['id'])) {
            return null;
        }

        return (string) $json['id'];
    }

    // --------------------------------------------------
    // TikTok: publish a photo post via Content Posting API
    // Uses DIRECT_POST with PULL_FROM_URL source.
    // Must call creator_info/query first to get privacy levels.
    // Unaudited apps are restricted to SELF_ONLY privacy.
    // --------------------------------------------------
    private static function publishTiktokPost(
        int    $post_id,
        string $content,
        string $access_token,
        string $image_url              = '',
        int    $unsplash_attachment_id = 0
    ): void {
        $image_url = $image_url !== '' ? $image_url : '';
        if ($image_url === '' && $unsplash_attachment_id > 0) {
            $image_url = (string) wp_get_attachment_url($unsplash_attachment_id);
        }

        if ($image_url === '' || !str_starts_with($image_url, 'https://')) {
            self::markFailed($post_id, 'no_valid_image_url', 'tiktok');
            return;
        }

        // Query creator info to get available privacy levels
        $creator_info = TikTokAuth::queryCreatorInfo();
        if (isset($creator_info['error'])) {
            self::markFailed($post_id, 'creator_info_failed', 'tiktok');
            return;
        }

        // Determine privacy level - use SELF_ONLY for unaudited apps
        $privacy_options = $creator_info['data']['privacy_level_options'] ?? [];
        $privacy_level   = 'SELF_ONLY';
        if (is_array($privacy_options) && in_array('PUBLIC_TO_EVERYONE', $privacy_options, true)) {
            $privacy_level = 'PUBLIC_TO_EVERYONE';
        }

        $payload = [
            'post_info' => [
                'description'     => $content,
                'privacy_level'   => $privacy_level,
                'disable_comment' => false,
                'auto_add_music'  => true,
            ],
            'source_info' => [
                'source'            => 'PULL_FROM_URL',
                'photo_cover_index' => 0,
                'photo_images'      => [$image_url],
            ],
            'post_mode'  => 'DIRECT_POST',
            'media_type' => 'PHOTO',
        ];

        $response = wp_remote_post(
            self::TIKTOK_PUBLISH_URL,
            [
                'timeout'   => 30,
                'sslverify' => true,
                'headers'   => [
                    'Authorization' => 'Bearer ' . $access_token,
                    'Content-Type'  => 'application/json; charset=UTF-8',
                ],
                'body' => wp_json_encode($payload),
            ]
        );

        if (is_wp_error($response)) {
            self::markFailed($post_id, 'tiktok_api_error: ' . $response->get_error_message(), 'tiktok');
            return;
        }

        $code = (int) wp_remote_retrieve_response_code($response);
        $data = json_decode(wp_remote_retrieve_body($response), true);

        if (!is_array($data)) {
            self::markFailed($post_id, 'tiktok_invalid_response', 'tiktok');
            return;
        }

        $error_code = (string) ($data['error']['code'] ?? '');
        if ($error_code !== 'ok' && $error_code !== '') {
            $error_msg = (string) ($data['error']['message'] ?? $error_code);
            self::markFailed($post_id, 'tiktok_publish_error: ' . $error_msg, 'tiktok');
            return;
        }

        if ($code < 200 || $code >= 300) {
            self::markFailed($post_id, 'tiktok_http_' . $code, 'tiktok');
            return;
        }

        $publish_id = (string) ($data['data']['publish_id'] ?? '');
        self::markPosted($post_id, $publish_id, 'tiktok');
    }

    // --------------------------------------------------
    // Google Business Profile: create a local post
    // --------------------------------------------------
    private static function publishGooglePost(
        int    $post_id,
        string $content,
        string $access_token,
        string $location_id,
        string $image_url              = '',
        int    $unsplash_attachment_id = 0
    ): void {
        if ($location_id === '') {
            self::markFailed($post_id, 'no_google_location_id', 'google');
            return;
        }
        // GBP local posts: 1500 char limit
        if (mb_strlen($content) > 1500) {
            $content = mb_substr($content, 0, 1497) . '...';
        }
        $payload = [
            'languageCode' => 'en',
            'summary'      => $content,
            'callToAction' => [
                'actionType' => 'LEARN_MORE',
                'url'        => (string) get_option('siteurl', ''),
            ],
            'topicType' => 'STANDARD',
        ];
        $effective_image_url = $image_url;
        if ($effective_image_url === '' && $unsplash_attachment_id > 0) {
            $effective_image_url = (string) wp_get_attachment_url($unsplash_attachment_id);
        }
        if ($effective_image_url !== '') {
            $payload['media'] = [[
                'mediaFormat' => 'PHOTO',
                'sourceUrl'   => $effective_image_url,
            ]];
        }
        $api_url  = 'https://mybusiness.googleapis.com/v4/' . $location_id . '/localPosts';
        $response = wp_remote_post(
            $api_url,
            [
                'timeout' => 30,
                'headers' => [
                    'Authorization' => 'Bearer ' . $access_token,
                    'Content-Type'  => 'application/json',
                ],
                'body' => wp_json_encode($payload),
            ]
        );
        if (is_wp_error($response)) {
            self::markFailed($post_id, 'google_http_error: ' . $response->get_error_message(), 'google');
            return;
        }
        $code = (int) wp_remote_retrieve_response_code($response);
        $raw  = wp_remote_retrieve_body($response);
        $json = json_decode($raw, true);
        if ($code !== 200 || !is_array($json) || empty($json['name'])) {
            $reason = 'google_api_error';
            if (is_array($json) && isset($json['error']['message'])) {
                $reason = substr((string) $json['error']['message'], 0, 191);
            }
            self::markFailed($post_id, $reason, 'google');
            return;
        }
        self::markPosted($post_id, (string) $json['name'], 'google');
    }

    // --------------------------------------------------
    // X: publish a tweet via the X API v2
    // Optionally attaches an image via the v2 media upload endpoint.
    // --------------------------------------------------
    private static function publishXPost(
        int    $post_id,
        string $content,
        string $access_token,
        int    $unsplash_attachment_id,
        string $image_url = ''
    ): void {
        // X hard limit: 280 characters
        if (mb_strlen($content) > 280) {
            $content = mb_substr($content, 0, 277) . '...';
        }

        $payload = ['text' => $content];

        // Attach image if available
        $effective_image_url = $image_url;
        if ($effective_image_url === '' && $unsplash_attachment_id > 0) {
            $effective_image_url = (string) wp_get_attachment_url($unsplash_attachment_id);
        }

        if ($effective_image_url !== '') {
            $media_id = self::uploadXMedia($effective_image_url, $access_token);
            if ($media_id !== null) {
                $payload['media'] = ['media_ids' => [$media_id]];
            }
        }

        $response = wp_remote_post(
            'https://api.x.com/2/tweets',
            [
                'timeout' => 30,
                'headers' => [
                    'Authorization' => 'Bearer ' . $access_token,
                    'Content-Type'  => 'application/json',
                ],
                'body' => wp_json_encode($payload),
            ]
        );

        if (is_wp_error($response)) {
            self::markFailed($post_id, 'x_http_error: ' . $response->get_error_message(), 'x');
            return;
        }

        $code = (int) wp_remote_retrieve_response_code($response);
        $raw  = wp_remote_retrieve_body($response);
        $json = json_decode($raw, true);

        if ($code !== 201 || !is_array($json) || empty($json['data']['id'])) {
            $reason = 'x_api_error';
            if (is_array($json) && isset($json['detail'])) {
                $reason = substr((string) $json['detail'], 0, 191);
            } elseif (is_array($json) && isset($json['errors'][0]['message'])) {
                $reason = substr((string) $json['errors'][0]['message'], 0, 191);
            }
            self::markFailed($post_id, $reason, 'x');
            return;
        }

        self::markPosted($post_id, (string) $json['data']['id'], 'x');
    }

    // --------------------------------------------------
    // X: upload media and return media_id string or null.
    // Uses the v2 media upload endpoint with OAuth 2.0 user token.
    // Requires media.write scope on the user's access token.
    // --------------------------------------------------
    private static function uploadXMedia(string $image_url, string $access_token): ?string
    {
        $tmp = download_url($image_url);
        if (is_wp_error($tmp)) {
            return null;
        }
        $mime    = mime_content_type($tmp);
        $allowed = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'];
        if ($mime === false || !in_array($mime, $allowed, true)) {
            wp_delete_file($tmp);
            return null;
        }
        $image_data = file_get_contents($tmp);
        wp_delete_file($tmp);
        if ($image_data === false || $image_data === '') {
            return null;
        }
        $boundary = bin2hex(random_bytes(16));
        $crlf     = "\r\n";
        $body     = '--' . $boundary . $crlf;
        $body    .= 'Content-Disposition: form-data; name="media_category"' . $crlf . $crlf;
        $body    .= 'tweet_image' . $crlf;
        $body    .= '--' . $boundary . $crlf;
        $body    .= 'Content-Disposition: form-data; name="media"; filename="image"' . $crlf;
        $body    .= 'Content-Type: ' . $mime . $crlf . $crlf;
        $body    .= $image_data . $crlf;
        $body    .= '--' . $boundary . '--' . $crlf;
        $response = wp_remote_post(
            'https://api.x.com/2/media/upload',
            [
                'timeout' => 30,
                'headers' => [
                    'Authorization' => 'Bearer ' . $access_token,
                    'Content-Type'  => 'multipart/form-data; boundary=' . $boundary,
                ],
                'body' => $body,
            ]
        );
        if (is_wp_error($response)) {
            return null;
        }
        $code = (int) wp_remote_retrieve_response_code($response);
        $raw_body = wp_remote_retrieve_body($response);
        $json = json_decode($raw_body, true);
        if (($code !== 200 && $code !== 201) || !is_array($json)) {
            return null;
        }
        $media_id = (string) ($json['data']['id'] ?? '');
        return $media_id !== '' ? $media_id : null;
    }

    // --------------------------------------------------
    // Status update helpers
    // --------------------------------------------------
    private static function markPosted(int $post_id, string $platform_post_id, string $channel = 'facebook'): void
    {
        global $wpdb;
        // Map channel to its dedicated post ID column.
        // Facebook, Instagram, and X have dedicated columns for comment linking.
        // TikTok and Google fall back to facebook_post_id until dedicated columns are added.
        $column_map = [
            'facebook'  => 'facebook_post_id',
            'instagram' => 'instagram_media_id',
            'x'         => 'x_post_id',
        ];
        $col = $column_map[$channel] ?? 'facebook_post_id';
        $wpdb->query(
            $wpdb->prepare(
                "UPDATE {$wpdb->prefix}amai_scheduled_posts
                 SET status         = 'posted',
                     {$col}         = %s,
                     posted_at      = %s,
                     failure_reason = NULL
                 WHERE id = %d",
                $platform_post_id,
                gmdate('Y-m-d H:i:s'),
                $post_id
            )
        );
    }
    private static function markFailed(int $post_id, string $reason, string $channel = 'facebook'): void
    {
        global $wpdb;

        $wpdb->query(
            $wpdb->prepare(
                "UPDATE {$wpdb->prefix}amai_scheduled_posts
                 SET status         = 'failed',
                     failure_reason = %s
                 WHERE id = %d",
                $reason,
                $post_id
            )
        );

        self::sendFailureEmail($post_id, $reason, $channel);
    }

    // Posts skipped due to missing image — no failure email, no retry noise.
    // Status is 'failed' so the feed UI surfaces it but no email fires.
    private static function markSkipped(int $post_id, string $reason): void
    {
        global $wpdb;

        $wpdb->query(
            $wpdb->prepare(
                "UPDATE {$wpdb->prefix}amai_scheduled_posts
                 SET status         = 'failed',
                     failure_reason = %s
                 WHERE id = %d",
                $reason,
                $post_id
            )
        );
    }

    // --------------------------------------------------
    // Send failure notification email to site admin.
    // --------------------------------------------------
    private static function sendFailureEmail(int $post_id, string $reason, string $channel = 'facebook'): void
    {
        global $wpdb;
        $row = $wpdb->get_row(
            $wpdb->prepare(
                "SELECT post_type, scheduled_date FROM {$wpdb->prefix}amai_scheduled_posts WHERE id = %d LIMIT 1",
                $post_id
            ),
            ARRAY_A
        );
        $to = (string) get_option('admin_email', '');
        if ($to === '') {
            return;
        }
        $post_type    = $row !== null ? ucfirst(str_replace('_', ' ', (string) ($row['post_type'] ?? 'unknown'))) : 'Unknown';
        $sched        = $row !== null ? (string) ($row['scheduled_date'] ?? 'unknown date') : 'unknown date';
        $site         = (string) get_option('blogname', 'AutoMarketer AI');
        $channel_map  = [
            'facebook'  => 'Facebook',
            'instagram' => 'Instagram',
            'x'         => 'X',
            'tiktok'    => 'TikTok',
            'linkedin'  => 'LinkedIn',
        ];
        $channel_name = $channel_map[$channel] ?? ucfirst($channel);
        $review_url   = admin_url('admin.php?page=automarketer-ai-social&channel=' . $channel);
        $subject      = '[' . $site . '] ' . $channel_name . ' post failed to publish';
        $headers      = ['Content-Type: text/html; charset=UTF-8'];
        ob_start();
        ?>
        <!DOCTYPE html>
        <html lang="en">
        <head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>
        <body style="margin:0;padding:0;background:#F9FAFB;font-family:Arial,sans-serif;">
        <table width="100%" cellpadding="0" cellspacing="0" style="background:#F9FAFB;padding:40px 0;">
          <tr><td align="center">
            <table width="560" cellpadding="0" cellspacing="0" style="background:#ffffff;border-radius:16px;border:1.5px solid #E7DDD5;overflow:hidden;">
              <tr><td style="background:#D97757;padding:24px 32px;">
                <p style="margin:0;font-size:12px;font-weight:700;letter-spacing:.08em;color:#fff;text-transform:uppercase;">AutoMarketer AI</p>
                <h1 style="margin:8px 0 0;font-size:22px;font-weight:800;color:#fff;"><?php echo esc_html($channel_name); ?> post failed to publish</h1>
              </td></tr>
              <tr><td style="padding:32px;">
                <p style="margin:0 0 24px;font-size:15px;color:#57534E;">A scheduled post could not be published. Here are the details:</p>
                <table width="100%" cellpadding="0" cellspacing="0" style="border:1.5px solid #E7DDD5;border-radius:12px;overflow:hidden;">
                  <tr style="background:#F9FAFB;">
                    <td style="padding:12px 16px;font-size:12px;font-weight:700;color:#A8A29E;width:130px;text-transform:uppercase;letter-spacing:.05em;border-bottom:1px solid #E7DDD5;">Channel</td>
                    <td style="padding:12px 16px;font-size:14px;color:#1C1917;border-bottom:1px solid #E7DDD5;"><?php echo esc_html($channel_name); ?></td>
                  </tr>
                  <tr>
                    <td style="padding:12px 16px;font-size:12px;font-weight:700;color:#A8A29E;text-transform:uppercase;letter-spacing:.05em;border-bottom:1px solid #E7DDD5;">Post ID</td>
                    <td style="padding:12px 16px;font-size:14px;color:#1C1917;border-bottom:1px solid #E7DDD5;"><?php echo esc_html((string) $post_id); ?></td>
                  </tr>
                  <tr style="background:#F9FAFB;">
                    <td style="padding:12px 16px;font-size:12px;font-weight:700;color:#A8A29E;text-transform:uppercase;letter-spacing:.05em;border-bottom:1px solid #E7DDD5;">Post type</td>
                    <td style="padding:12px 16px;font-size:14px;color:#1C1917;border-bottom:1px solid #E7DDD5;"><?php echo esc_html($post_type); ?></td>
                  </tr>
                  <tr>
                    <td style="padding:12px 16px;font-size:12px;font-weight:700;color:#A8A29E;text-transform:uppercase;letter-spacing:.05em;border-bottom:1px solid #E7DDD5;">Scheduled</td>
                    <td style="padding:12px 16px;font-size:14px;color:#1C1917;border-bottom:1px solid #E7DDD5;"><?php echo esc_html($sched); ?></td>
                  </tr>
                  <tr>
                    <td style="padding:12px 16px;font-size:12px;font-weight:700;color:#A8A29E;text-transform:uppercase;letter-spacing:.05em;">Reason</td>
                    <td style="padding:12px 16px;font-size:14px;color:#C0392B;"><?php echo esc_html($reason); ?></td>
                  </tr>
                </table>
                <div style="margin-top:28px;text-align:center;">
                  <a href="<?php echo esc_url($review_url); ?>" style="display:inline-block;background:#D97757;color:#fff;font-size:14px;font-weight:700;text-decoration:none;padding:12px 28px;border-radius:8px;">Review in Dashboard</a>
                </div>
              </td></tr>
              <tr><td style="padding:20px 32px;border-top:1.5px solid #E7DDD5;text-align:center;">
                <p style="margin:0;font-size:12px;color:#A8A29E;">AutoMarketer AI - your AI marketing engine</p>
              </td></tr>
            </table>
          </td></tr>
        </table>
        </body></html>
        <?php
        $message = (string) ob_get_clean();
        wp_mail($to, $subject, $message, $headers);
    }

    private static function getPageToken(): ?string
    {
        return FacebookAuth::getPageToken();
    }

    // --------------------------------------------------
    // Retrieve the client timezone string from the
    // business profile. Falls back to UTC if unset.
    // --------------------------------------------------
    private static function getClientTimezone(): string
    {
        global $wpdb;

        $tz = $wpdb->get_var(
            "SELECT timezone FROM {$wpdb->prefix}amai_business_profile LIMIT 1"
        );

        if (empty($tz)) {
            return 'UTC';
        }

        try {
            new \DateTimeZone((string) $tz);
            return (string) $tz;
        } catch (\Throwable) {
            return 'UTC';
        }
    }

    // --------------------------------------------------
    // Public test entry point for SettingsPage.
    // Sends a sample posting failure email to the admin.
    // --------------------------------------------------
    public static function sendTestFailureEmail(): void
    {
        self::sendFailureEmail(0, 'Test failure -- no real post', 'facebook');
    }
}