<?php
// ============================================================
//  MediCore — Local → Cloud Sync Worker
//  File: sync/worker.php
//
//  A CLI script (NOT a web endpoint) that drains sync_queue and
//  pushes changes to the Cloud Server over HTTPS. Run it on a
//  schedule — see the "Running this" section at the bottom of this
//  file, or SYNC_ARCHITECTURE.md §Deployment.
//
//  Why CLI and not a web request / SSE / long-lived connection:
//  this codebase already had one incident where browser tabs
//  holding long-lived SSE connections open exhausted XAMPP's small
//  default Apache worker-thread pool on Windows (see comments in
//  assets/topbar.js and api/stock-sync.php). A CLI process making
//  outbound HTTP calls on a timer carries none of that risk — it
//  never occupies an Apache worker thread, inbound or otherwise.
//
//  Safe to invoke every few seconds (single-pass mode, the default)
//  via Windows Task Scheduler, or left running continuously with
//  --loop for a more "real-time" feel. Either way it is idempotent:
//  running it twice concurrently is harmless (see the row-locking
//  note in fetchBatch()).
// ============================================================

if (PHP_SAPI !== 'cli') {
    http_response_code(403);
    exit('sync/worker.php is a CLI-only script and cannot be run over HTTP.');
}

chdir(__DIR__);
require_once '../includes/db.php';
require_once '../includes/sync-config.php';

ensure_sync_tables();

$branchId = SYNC_BRANCH_ID;
$loop     = in_array('--loop', $argv ?? [], true);
$verbose  = in_array('--verbose', $argv ?? [], true) || in_array('-v', $argv ?? [], true);

function w_log(string $msg, bool $verbose = true): void {
    global $verbose;
    if (!$verbose) return;
    echo '[' . date('Y-m-d H:i:s') . '] ' . $msg . PHP_EOL;
}

do {
    run_sync_cycle($branchId);
    if ($loop) sleep(5);
} while ($loop);

// ── One full cycle: check connectivity, drain queue, update status ──
function run_sync_cycle(int $branchId): void {
    ensure_branch_status_row($branchId);

    $pending = (int) db_val(
        "SELECT COUNT(*) FROM sync_queue WHERE branch_id = ? AND status IN ('pending','failed')",
        [$branchId]
    );
    db_run("UPDATE sync_status SET pending_count = ? WHERE branch_id = ?", [$pending, $branchId]);

    if ($pending === 0) {
        w_log("Nothing to sync (branch $branchId).");
        return;
    }

    if (!cloud_is_reachable()) {
        db_run(
            "UPDATE sync_status
                SET is_online = 0, last_attempt_at = NOW(6)
              WHERE branch_id = ?",
            [$branchId]
        );
        w_log("Cloud unreachable — $pending row(s) remain queued locally. Local operations are unaffected.");
        return;
    }

    $batch = fetch_batch($branchId);
    if (!$batch) {
        w_log("No batch ready yet (all pending rows are backing off).");
        return;
    }

    [$sendRows, $queueIdsByKey] = coalesce_batch($batch);
    $batchId = bin2hex(random_bytes(8));

    $result = push_batch($branchId, $batchId, $sendRows);

    if ($result['ok']) {
        apply_success($branchId, $batchId, $batch, $result);
        w_log("Synced " . count($sendRows) . " record(s) (" . count($batch) . " queue row(s) coalesced) in batch $batchId.");
    } else {
        apply_failure($branchId, $batchId, $batch, $result);
        w_log("Sync batch $batchId FAILED: " . $result['error']);
    }
}

function ensure_branch_status_row(int $branchId): void {
    db_run(
        "INSERT IGNORE INTO sync_status (branch_id, branch_name) VALUES (?, ?)",
        [$branchId, defined('SYNC_BRANCH_NAME') ? SYNC_BRANCH_NAME : "Branch $branchId"]
    );
}

// Cheap, fast reachability probe — short timeout on purpose. This is
// not the real sync request, just a "is it worth trying" check so a
// dead/unreachable Cloud Server doesn't stall the worker on a long
// connect timeout every cycle.
function cloud_is_reachable(): bool {
    if (!SYNC_CLOUD_URL || str_contains(SYNC_CLOUD_URL, 'YOUR-CLOUD-SERVER')) {
        return false; // placeholder still in place — treat as offline
    }
    $ch = curl_init(SYNC_CLOUD_URL);
    curl_setopt_array($ch, [
        CURLOPT_NOBODY         => true,   // HEAD-style — don't fetch a body
        CURLOPT_CONNECTTIMEOUT => 3,
        CURLOPT_TIMEOUT        => 4,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_SSL_VERIFYPEER => true,
    ]);
    curl_exec($ch);
    $errno = curl_errno($ch);
    curl_close($ch);
    // Any response at all (even a 4xx from a HEAD it doesn't support)
    // means the network path and TLS handshake work — good enough to
    // attempt the real batch push. Only a curl-level error (DNS fail,
    // connection refused, timeout) means genuinely offline.
    return $errno === 0;
}

// Pull one page of pending/backed-off-but-due rows. Uses a UPDATE...
// then SELECT pattern would be nicer, but a simple SELECT here is
// fine even with two workers running concurrently (unusual, but not
// forbidden) because apply_success()/apply_failure() only ever
// transition a row status conditionally on the status/attempts it
// last read — a lost race just means the row gets picked up again
// next cycle, which is safe (send is idempotent on the Cloud side).
function fetch_batch(int $branchId): array {
    return db_rows(
        "SELECT * FROM sync_queue
          WHERE branch_id = ?
            AND status IN ('pending','failed')
            AND (next_retry_at IS NULL OR next_retry_at <= NOW(6))
          ORDER BY id
          LIMIT " . (int) SYNC_BATCH_SIZE,
        [$branchId]
    );
}

// De-duplicate: if the same (table_name, record_id) changed several
// times before the worker got to it (e.g. a sale created then its
// due collected within the same cycle), only the LATEST row's
// payload needs to travel over the wire — it already reflects every
// earlier change. All the superseded queue_ids are still tracked so
// every one of them gets marked 'done' once the latest succeeds; we
// never resend or lose an intermediate state, we just don't pay for
// transmitting it separately. A DELETE always wins over any earlier
// INSERT/UPDATE for the same key, since the end state is "gone".
function coalesce_batch(array $rows): array {
    $latest = [];   // key => row (last write wins, DELETE always wins)
    $queueIdsByKey = []; // key => [queue_id, ...]

    foreach ($rows as $row) {
        $key = $row['table_name'] . '#' . $row['record_id'];
        $queueIdsByKey[$key][] = (int) $row['id'];

        if (!isset($latest[$key])) {
            $latest[$key] = $row;
            continue;
        }
        if ($row['operation_type'] === 'DELETE' || $latest[$key]['operation_type'] !== 'DELETE') {
            $latest[$key] = $row; // later row in id order supersedes
        }
    }

    $sendRows = array_values(array_map(function ($row) {
        return [
            'queue_id'       => (int) $row['id'],
            'table_name'     => $row['table_name'],
            'record_id'      => (int) $row['record_id'],
            'operation_type' => $row['operation_type'],
            'payload'        => $row['payload'] !== null ? json_decode($row['payload'], true) : null,
        ];
    }, $latest));

    return [$sendRows, $queueIdsByKey];
}

function push_batch(int $branchId, string $batchId, array $sendRows): array {
    $body = json_encode([
        'branch_id' => $branchId,
        'batch_id'  => $batchId,
        'rows'      => $sendRows,
    ], JSON_UNESCAPED_UNICODE);

    $signature = hash_hmac('sha256', $body, SYNC_SHARED_SECRET);

    $started = microtime(true);
    $ch = curl_init(SYNC_CLOUD_URL);
    curl_setopt_array($ch, [
        CURLOPT_POST           => true,
        CURLOPT_POSTFIELDS     => $body,
        CURLOPT_HTTPHEADER     => [
            'Content-Type: application/json',
            'X-Sync-Branch: ' . $branchId,
            'X-Sync-Signature: ' . $signature,
        ],
        CURLOPT_CONNECTTIMEOUT => 5,
        CURLOPT_TIMEOUT        => SYNC_HTTP_TIMEOUT,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_SSL_VERIFYPEER => true,
    ]);
    $response  = curl_exec($ch);
    $httpCode  = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    $curlErr   = curl_error($ch);
    curl_close($ch);
    $durationMs = (int) round((microtime(true) - $started) * 1000);

    if ($response === false) {
        return ['ok' => false, 'http_status' => 0, 'error' => "cURL error: $curlErr", 'duration_ms' => $durationMs];
    }
    if ($httpCode !== 200) {
        return ['ok' => false, 'http_status' => $httpCode, 'error' => "HTTP $httpCode: " . substr($response, 0, 300), 'duration_ms' => $durationMs];
    }
    $data = json_decode($response, true);
    if (!is_array($data) || empty($data['success'])) {
        return ['ok' => false, 'http_status' => $httpCode, 'error' => substr($response, 0, 300), 'duration_ms' => $durationMs];
    }

    // applied: list of queue_ids the Cloud side actually applied.
    // Cloud applies rows independently, so a partial batch failure
    // (one bad row) doesn't block the rest — we only mark 'done'
    // exactly what the Cloud confirmed, and retry the remainder.
    return [
        'ok'          => true,
        'http_status' => $httpCode,
        'applied'     => $data['applied'] ?? [],
        'rejected'    => $data['rejected'] ?? [],
        'duration_ms' => $durationMs,
    ];
}

function apply_success(int $branchId, string $batchId, array $originalBatch, array $result): void {
    $applied  = array_flip($result['applied']);
    $rejected = $result['rejected'] ?? [];
    $maxAppliedId = 0;

    foreach ($originalBatch as $row) {
        $qid = (int) $row['id'];
        // A row is "done" if it was superseded by a later row in this
        // same batch for the same key (coalescing) OR the Cloud
        // explicitly confirmed it. Superseded rows were never sent on
        // their own, but their change is subsumed by the row that WAS
        // sent and applied, so they're equally safe to close out.
        $wasSent = isset($applied[$qid]) || in_array($qid, array_column($rejected, 'queue_id'), true);

        if (isset($applied[$qid]) || !$wasSent) {
            db_run("UPDATE sync_queue SET status='done', sent_at=NOW(6) WHERE id = ?", [$qid]);
            db_run(
                "INSERT INTO sync_log (queue_id, branch_id, table_name, record_id, operation_type, batch_id, http_status, success, duration_ms)
                 VALUES (?,?,?,?,?,?,?,1,?)",
                [$qid, $branchId, $row['table_name'], $row['record_id'], $row['operation_type'], $batchId, $result['http_status'], $result['duration_ms']]
            );
            $maxAppliedId = max($maxAppliedId, $qid);
        }
    }

    // Anything the Cloud explicitly rejected (e.g. malformed row) is
    // logged and left 'pending' for a human to investigate via
    // sync_log — it does NOT get silently retried forever, but it
    // also doesn't block the rest of the queue from draining.
    foreach ($rejected as $r) {
        $qid = (int) ($r['queue_id'] ?? 0);
        if (!$qid) continue;
        db_run("UPDATE sync_queue SET status='failed', last_error=? WHERE id = ?", [$r['error'] ?? 'Rejected by Cloud', $qid]);
        db_run(
            "INSERT INTO sync_log (queue_id, branch_id, table_name, record_id, operation_type, batch_id, http_status, success, duration_ms, response_snip)
             VALUES (?,?,?,?,?,?,?,0,?,?)",
            [$qid, $branchId, $r['table_name'] ?? '', $r['record_id'] ?? 0, $r['operation_type'] ?? '', $batchId, $result['http_status'], $result['duration_ms'], substr($r['error'] ?? '', 0, 500)]
        );
    }

    $pending = (int) db_val("SELECT COUNT(*) FROM sync_queue WHERE branch_id = ? AND status IN ('pending','failed')", [$branchId]);
    db_run(
        "UPDATE sync_status
            SET is_online = 1, last_attempt_at = NOW(6), last_success_at = NOW(6),
                consecutive_failures = 0, pending_count = ?,
                last_synced_queue_id = GREATEST(last_synced_queue_id, ?)
          WHERE branch_id = ?",
        [$pending, $maxAppliedId, $branchId]
    );
}

function apply_failure(int $branchId, string $batchId, array $originalBatch, array $result): void {
    foreach ($originalBatch as $row) {
        $attempts = (int) $row['attempts'] + 1;
        $status   = $attempts >= SYNC_MAX_ATTEMPTS ? 'failed' : 'pending';
        $delayMs  = min(SYNC_BACKOFF_MAX_MS, SYNC_BACKOFF_BASE_MS * (2 ** min($attempts, 10)));

        db_run(
            "UPDATE sync_queue
                SET attempts = ?, status = ?, last_error = ?,
                    next_retry_at = DATE_ADD(NOW(6), INTERVAL ? MICROSECOND)
              WHERE id = ?",
            [$attempts, $status, $result['error'], $delayMs * 1000, $row['id']]
        );
        db_run(
            "INSERT INTO sync_log (queue_id, branch_id, table_name, record_id, operation_type, batch_id, http_status, success, duration_ms, response_snip)
             VALUES (?,?,?,?,?,?,?,0,?,?)",
            [$row['id'], $branchId, $row['table_name'], $row['record_id'], $row['operation_type'], $batchId, $result['http_status'], $result['duration_ms'], substr($result['error'], 0, 500)]
        );
    }

    $pending = (int) db_val("SELECT COUNT(*) FROM sync_queue WHERE branch_id = ? AND status IN ('pending','failed')", [$branchId]);
    db_run(
        "UPDATE sync_status
            SET is_online = 0, last_attempt_at = NOW(6),
                consecutive_failures = consecutive_failures + 1,
                pending_count = ?
          WHERE branch_id = ?",
        [$pending, $branchId]
    );
}

// ── Running this ─────────────────────────────────────────────
// Recommended (Windows Task Scheduler, since XAMPP/Windows has no
// native cron): create a task that runs every 1 minute:
//   Program:  C:\xampp\php\php.exe
//   Args:     C:\xampp\htdocs\medicore28\sync\worker.php
//   Start in: C:\xampp\htdocs\medicore28\sync
// Single-pass mode (the default, no --loop) processes one batch and
// exits — safe to overlap with the next scheduled run since fetch/
// apply are idempotent (see fetch_batch()'s comment).
//
// For closer-to-real-time sync without waiting on Task Scheduler's
// 1-minute floor, run instead as a persistent process (e.g. via NSSM
// as a Windows service, or `start /B php worker.php --loop`):
//   php sync/worker.php --loop --verbose
// This sleeps 5s between cycles — still a plain outbound HTTP client
// on a timer, not a held-open connection, so it does not reintroduce
// the Apache-worker-thread problem that ruled out SSE elsewhere.
