#!/usr/bin/env php
<?php
/**
 * WordPress update queue worker.
 *
 * The Update Now flow on the WordPress Management page can queue an update to
 * run AFTER a backup completes (queueUpdateWithBackup). That handler inserts a
 * row into wp_update_queue with status='waiting_for_backup' — but until this
 * worker existed, nothing ever transitioned that row, so backup-then-update
 * jobs sat orphaned forever and the wp_update_history row stuck at
 * status='in_progress'.
 *
 * State machine:
 *
 *   waiting_for_backup --(backup completed)-->  ready
 *   waiting_for_backup --(backup failed)----->  failed   (also marks wp_update_history failed)
 *   ready              --(claim)------------->  processing
 *   processing         --(updateCore/All)----> completed | failed
 *   processing         --(>30 min stale)----->  failed   (also marks wp_update_history failed)
 *
 * Cron-driven: locking is via cron's flock wrapper, not in-script.
 */

// Bootstrap directly from config.php — do NOT include auto-prepend.php here.
// auto-prepend redirects unauthenticated requests to login.php and exit()s,
// which terminates CLI scripts silently (the same trap that has been quietly
// breaking /root/whp/scripts/wordpress-auto-update.php for weeks). Match the
// pattern used by web-files/scripts/backup-worker.php instead.
require_once('/docker/whp/web/configs/config.php');
require_once('/docker/whp/web/libs/mysqlmgmt.php');
require_once('/docker/whp/web/libs/WordPressManager.php');

use WHP\WordPressManager;

const STUCK_PROCESSING_MINUTES = 30;
const LOG_FILE = '/var/log/whp-wp-update-queue.log';

function log_msg(string $msg): void {
    $line = '[' . date('Y-m-d H:i:s') . "] [WPUpdateQueueWorker] $msg";
    error_log($line);
    @file_put_contents(LOG_FILE, $line . "\n", FILE_APPEND | LOCK_EX);
}

function failUpdateHistory(\PDO $db, int $updateId, string $error): void {
    $stmt = $db->prepare(
        "UPDATE wp_update_history
         SET status = 'failed', error_message = ?, completed_at = NOW()
         WHERE id = ? AND status = 'in_progress'"
    );
    $stmt->execute([$error, $updateId]);
}

/**
 * Promote rows whose backup finished, fail rows whose backup failed/disappeared.
 */
function transitionWaitingRows(\PDO $db): void {
    // Backup completed → ready
    $stmt = $db->prepare(
        "UPDATE wp_update_queue q
         JOIN backup_history b ON b.id = q.backup_id
         SET q.status = 'ready'
         WHERE q.status = 'waiting_for_backup'
           AND b.status = 'completed'"
    );
    $stmt->execute();
    if ($stmt->rowCount() > 0) {
        log_msg("Promoted {$stmt->rowCount()} queue row(s) to 'ready'");
    }

    // Backup is in any terminal-not-completed state (failed/deleted/cancelled)
    // OR the backup row no longer exists at all → fail the queued update.
    // Capture rows first so we can also mark their wp_update_history entries
    // failed (otherwise the UI poller would spin on 'in_progress' forever).
    $stmt = $db->prepare(
        "SELECT q.id, q.update_id, q.backup_id, b.status AS backup_status, b.error_message
         FROM wp_update_queue q
         LEFT JOIN backup_history b ON b.id = q.backup_id
         WHERE q.status = 'waiting_for_backup'
           AND (b.id IS NULL OR b.status NOT IN ('pending', 'claimed', 'running', 'completed'))"
    );
    $stmt->execute();
    $failedRows = $stmt->fetchAll(\PDO::FETCH_ASSOC);

    foreach ($failedRows as $row) {
        if ($row['backup_status'] === null) {
            $msg = "Backup id={$row['backup_id']} no longer exists";
        } else {
            $msg = "Backup ended in state '{$row['backup_status']}' before update could run"
                . (!empty($row['error_message']) ? ': ' . $row['error_message'] : '');
        }
        $up = $db->prepare(
            "UPDATE wp_update_queue SET status = 'failed', error_message = ?, completed_at = NOW() WHERE id = ?"
        );
        $up->execute([$msg, $row['id']]);
        failUpdateHistory($db, (int)$row['update_id'], $msg);
        log_msg("Failed queue id={$row['id']} (update_id={$row['update_id']}) — $msg");
    }
}

/**
 * Recover rows stuck in 'processing' past the timeout (worker died mid-update).
 */
function cleanupStuckProcessing(\PDO $db): void {
    $stmt = $db->prepare(
        "SELECT id, update_id FROM wp_update_queue
         WHERE status = 'processing'
           AND started_at < DATE_SUB(NOW(), INTERVAL " . (int)STUCK_PROCESSING_MINUTES . " MINUTE)"
    );
    $stmt->execute();
    $stuck = $stmt->fetchAll(\PDO::FETCH_ASSOC);

    foreach ($stuck as $row) {
        $msg = 'Update worker died or timed out (processing > ' . STUCK_PROCESSING_MINUTES . ' min)';
        $up = $db->prepare(
            "UPDATE wp_update_queue SET status = 'failed', error_message = ?, completed_at = NOW() WHERE id = ?"
        );
        $up->execute([$msg, $row['id']]);
        failUpdateHistory($db, (int)$row['update_id'], $msg);
        log_msg("Recovered stuck queue id={$row['id']} (update_id={$row['update_id']})");
    }
}

/**
 * Atomically claim one ready row by flipping it to 'processing'. Returns the
 * full row, or null if another worker beat us to every candidate.
 */
function claimNextReady(\PDO $db): ?array {
    $db->beginTransaction();
    try {
        $stmt = $db->prepare(
            "SELECT * FROM wp_update_queue
             WHERE status = 'ready'
             ORDER BY created_at ASC
             LIMIT 1
             FOR UPDATE"
        );
        $stmt->execute();
        $row = $stmt->fetch(\PDO::FETCH_ASSOC);
        if (!$row) {
            $db->commit();
            return null;
        }
        $up = $db->prepare(
            "UPDATE wp_update_queue
             SET status = 'processing', started_at = NOW()
             WHERE id = ? AND status = 'ready'"
        );
        $up->execute([$row['id']]);
        if ($up->rowCount() !== 1) {
            $db->commit();
            return null;
        }
        $db->commit();
        return $row;
    } catch (\Throwable $e) {
        $db->rollBack();
        throw $e;
    }
}

function runQueuedUpdate(\PDO $db, WordPressManager $wp, array $row): void {
    $queueId    = (int)$row['id'];
    $siteId     = (int)$row['site_id'];
    $updateId   = (int)$row['update_id'];
    $updateType = $row['update_type'];

    log_msg("Running queued update queue_id=$queueId site_id=$siteId update_id=$updateId type=$updateType");

    try {
        if ($updateType === 'core') {
            $wp->performCoreUpdateImmediate($siteId, $updateId);
        } else {
            // 'all' (and any future broader type) goes through the comprehensive path
            $wp->performCompleteUpdateImmediate($siteId, $updateId);
        }

        $up = $db->prepare(
            "UPDATE wp_update_queue SET status = 'completed', completed_at = NOW() WHERE id = ?"
        );
        $up->execute([$queueId]);
        log_msg("Completed queue_id=$queueId");
    } catch (\Throwable $e) {
        $err = $e->getMessage();
        log_msg("Update failed for queue_id=$queueId: $err");
        $up = $db->prepare(
            "UPDATE wp_update_queue SET status = 'failed', error_message = ?, completed_at = NOW() WHERE id = ?"
        );
        $up->execute([$err, $queueId]);
        // performCoreUpdateImmediate / performCompleteUpdateImmediate already
        // call recordUpdateFailed/recordUpdateComplete on their own, but if
        // they threw before doing so, mirror that into wp_update_history.
        failUpdateHistory($db, $updateId, $err);
    }
}

if (php_sapi_name() !== 'cli') {
    fwrite(STDERR, "This script must run from the command line\n");
    exit(1);
}

$mysql = new \mysqlmgmt();
$db = $mysql->getMySQLConnection();
$db->query("USE whp");

try {
    cleanupStuckProcessing($db);
    transitionWaitingRows($db);

    // Process at most a handful per run so a single tick can't tie up the
    // worker for a long time. Cron fires us again every minute.
    $maxPerRun = 3;
    for ($i = 0; $i < $maxPerRun; $i++) {
        $row = claimNextReady($db);
        if (!$row) {
            break;
        }
        $wp = new WordPressManager();
        runQueuedUpdate($db, $wp, $row);
    }
} catch (\Throwable $e) {
    log_msg("Fatal: " . $e->getMessage() . "\n" . $e->getTraceAsString());
    exit(1);
}

exit(0);
