<?php
/**
 * Abstract base for AF → wpdeploy sync streams that follow the
 * "synced_at lifecycle" durability pattern:
 *
 *   1. read rows where synced_at IS NULL ORDER BY id ASC LIMIT batch_size
 *   2. POST to wpdeploy
 *   3. on confirmed 200, mark those rows synced_at = NOW()
 *   4. retry on next cron run if step 2 failed
 *
 * Subclasses provide:
 *   - $endpoint           — wpdeploy route ('sync-user-interests' etc.)
 *   - $table              — full table name (or override read_batch())
 *   - $cron_hook          — WP-Cron hook name
 *   - $cron_schedule      — 'hourly', 'bp_ten_minutely', 'daily', etc.
 *   - $batch_size         — rows per cycle (default 200)
 *   - $health_key         — slot in the sync_health_json reported back
 *   - read_batch() / mark_synced() / build_payload()
 */

if (!defined('ABSPATH')) exit;

abstract class AF_Sync_Base {

    protected $endpoint;
    protected $table;
    protected $cron_hook;
    protected $cron_schedule = 'hourly';
    protected $batch_size = 200;
    protected $health_key;

    public function register() {
        add_action($this->cron_hook, array($this, 'run'));
        if (!wp_next_scheduled($this->cron_hook)) {
            wp_schedule_event(time(), $this->cron_schedule, $this->cron_hook);
        }
    }

    /**
     * Cron entry point. Reads a batch, POSTs, marks synced on confirmation.
     */
    public function run() {
        if (!$this->is_configured()) return false;

        $rows = $this->read_batch();

        // Always POST a heartbeat — even when there are no rows to send.
        // This lets wpdeploy distinguish "cron fired, nothing to sync" from
        // "cron is broken / silent" and keeps sync_health_json green on
        // sites that have nothing to ship yet.
        $payload = $rows ? $this->build_payload($rows) : array('records' => array());
        $payload['site_url']  = home_url();
        $payload['site_name'] = get_bloginfo('name');
        $payload['sync_time'] = current_time('mysql', true);

        $resp = $this->post_to_wpdeploy($payload);

        if (is_array($resp) && empty($resp['error']) && !empty($resp['success'])) {
            if ($rows) $this->mark_synced($rows, $resp);
            $this->record_status(empty($rows) ? 'idle' : 'success', count($rows));
            return true;
        }

        $err = is_array($resp) && !empty($resp['error']) ? $resp['error'] : 'no_response';
        $this->record_status('api_error', 0, $err);
        return false;
    }

    /** Subclass: pull next batch of unsynced rows. */
    abstract protected function read_batch();

    /** Subclass: build the JSON payload for wpdeploy. */
    abstract protected function build_payload($rows);

    /**
     * Subclass: mark rows as synced after a confirmed 200 response.
     * Default impl assumes rows are objects/arrays with an `id` column on
     * a table with a synced_at column.
     */
    protected function mark_synced($rows, $response) {
        if (empty($rows) || empty($this->table)) return;

        $ids = array();
        foreach ($rows as $r) {
            if (is_array($r) && isset($r['id']))     $ids[] = (int) $r['id'];
            elseif (is_object($r) && isset($r->id)) $ids[] = (int) $r->id;
        }
        if (empty($ids)) return;

        global $wpdb;
        $placeholders = implode(',', array_fill(0, count($ids), '%d'));
        $wpdb->query($wpdb->prepare(
            "UPDATE {$this->table} SET synced_at = UTC_TIMESTAMP() WHERE id IN ($placeholders)",
            $ids
        ));
    }

    protected function is_configured() {
        $url = get_option('auctionforge_update_server_url', '');
        $key = get_option('auctionforge_update_api_key', '');
        return !empty($url) && !empty($key);
    }

    protected function post_to_wpdeploy($payload) {
        $url = rtrim(get_option('auctionforge_update_server_url'), '/')
             . '/api.php/' . ltrim($this->endpoint, '/');

        $resp = wp_remote_post($url, array(
            'timeout' => 30,
            'headers' => array(
                'X-API-Key'    => get_option('auctionforge_update_api_key'),
                'Content-Type' => 'application/json',
            ),
            'body' => wp_json_encode($payload),
        ));

        if (is_wp_error($resp)) {
            return array('error' => $resp->get_error_message());
        }

        $body = wp_remote_retrieve_body($resp);
        return json_decode($body, true);
    }

    protected function record_status($status, $count, $error_msg = null) {
        update_option('auctionforge_last_' . $this->endpoint . '_status', $status, 'no');
        update_option('auctionforge_last_' . $this->endpoint . '_at', current_time('mysql', true), 'no');
        update_option('auctionforge_last_' . $this->endpoint . '_count', (int) $count, 'no');
        if ($error_msg !== null) {
            update_option('auctionforge_last_' . $this->endpoint . '_error', substr((string) $error_msg, 0, 500), 'no');
        } else {
            delete_option('auctionforge_last_' . $this->endpoint . '_error');
        }
    }

    protected function table_exists($name) {
        global $wpdb;
        return ($wpdb->get_var($wpdb->prepare("SHOW TABLES LIKE %s", $name)) === $name);
    }
}
