<?php
// ============================================================
//  MediCore — Cloud Sync Receiver
//  File: api/sync-receiver.php
//  DEPLOY THIS COPY ON THE CLOUD SERVER (not the Local Server).
//
//  Accepts batches pushed by sync/worker.php running on one or more
//  Local Servers, applies them idempotently, and reports back exactly
//  which queue rows were applied so the Local worker can mark them
//  'done'. See SYNC_ARCHITECTURE.md for the full design.
//
//  This is a machine-to-machine endpoint, not a browser one — it is
//  intentionally NOT gated by require_login()/session auth. Instead
//  every request must carry a valid HMAC-SHA256 signature (see
//  verify_signature()) computed with the shared secret configured in
//  includes/sync-config.php. Keep that secret only on the Local and
//  Cloud servers that need to talk to each other — never in a browser
//  or a public repo.
// ============================================================

require_once '../includes/db.php';
require_once '../includes/sync-config.php';

header('Content-Type: application/json');

// ============================================================
//  Constants and function definitions FIRST, dispatch logic LAST.
//  (Top-level `const` executes in normal script flow order, not
//  hoisted like function declarations — defining these after code
//  that calls functions which reference them would reproduce the
//  exact "Undefined constant" bug already fixed once elsewhere in
//  this app. See includes/db.php-adjacent notes / api/products.php.)
// ============================================================

// Tables whose payload embeds a child "items" array (the Local side
// sends parent + line items as one denormalized snapshot — see the
// comment on enqueue_sync() calls in api/sales.php etc.). Maps each
// parent table to the real child table its items belong to.
const ITEM_TABLE_MAP = [
    'sales'            => 'sale_items',
    'purchases'        => 'purchase_items',
    'sales_returns'    => 'sales_return_items',
    'purchase_returns' => 'purchase_return_items',
];

// Hard-delete tables (no is_deleted column on Local) — a DELETE
// operation_type for these means physically remove the row. Every
// other table's "deletion" arrives as operation_type=UPDATE with
// is_deleted=1 already set in the payload (Local's own soft-delete
// convention), which the generic upsert path handles with no special
// casing needed.
const HARD_DELETE_TABLES = ['cash_ledger', 'expenses', 'incomes'];

function apply_one_row(int $branchId, int $queueId, string $table, int $recordId, string $op, $payload): void {
    if (!preg_match('/^[a-zA-Z_][a-zA-Z0-9_]*$/', $table)) {
        throw new RuntimeException("Refusing unsafe table name: $table");
    }

    // Idempotency / ordering guard: only apply if this queue_id is
    // newer than the last one we applied for this exact record. Makes
    // retried/duplicate/out-of-order deliveries safe no-ops instead of
    // silently reverting a newer state with an older one.
    $last = db_val(
        "SELECT last_queue_id FROM sync_applied
          WHERE branch_id = ? AND table_name = ? AND record_id = ?",
        [$branchId, $table, $recordId]
    );
    if ($last !== null && (int) $last >= $queueId) {
        log_cloud_sync($queueId, $branchId, $table, $recordId, $op, '', true, 'Skipped — already applied (stale/duplicate delivery)');
        return; // treated as success — Local can still mark it done
    }

    ensure_branch_column($table);

    if ($op === 'DELETE') {
        // Hard tables get a real DELETE; everything else that might
        // send DELETE (defensive — Local's own convention is to send
        // soft-deletes as UPDATE) also just gets removed on Cloud,
        // since there's no better fallback without knowing the table's
        // soft-delete convention here.
        db_run("DELETE FROM `$table` WHERE branch_id = ? AND id = ?", [$branchId, $recordId]);
    } else {
        // INSERT/UPDATE both go through the same UPSERT — the Cloud
        // side never needs to distinguish them: "make this record look
        // like this snapshot" is correct either way.
        if (!is_array($payload)) {
            throw new RuntimeException('INSERT/UPDATE requires a payload');
        }
        $items = null;
        if (isset(ITEM_TABLE_MAP[$table]) && isset($payload['items']) && is_array($payload['items'])) {
            $items = $payload['items'];
            unset($payload['items']);
        }
        upsert_row($table, $payload, $branchId);

        if ($items !== null) {
            $childTable = ITEM_TABLE_MAP[$table];
            ensure_branch_column($childTable);
            foreach ($items as $item) {
                if (!is_array($item) || !isset($item['id'])) continue;
                upsert_row($childTable, $item, $branchId);
            }
        }
    }

    db_run(
        "INSERT INTO sync_applied (branch_id, table_name, record_id, last_queue_id)
         VALUES (?,?,?,?)
         ON DUPLICATE KEY UPDATE last_queue_id = VALUES(last_queue_id), updated_at = NOW(6)",
        [$branchId, $table, $recordId, $queueId]
    );
    log_cloud_sync($queueId, $branchId, $table, $recordId, $op, '', true, null);
}

// Generic dynamic UPSERT — builds "INSERT ... ON DUPLICATE KEY UPDATE"
// from whatever columns are present in $payload, keyed by the
// (branch_id, id) unique index added by ensure_branch_column(). Works
// for any of the tables this system syncs without a hand-written
// function per table — new tables just need adding to a wiring point
// in the operational endpoint (enqueue_sync calls), nothing here.
function upsert_row(string $table, array $payload, int $branchId): void {
    if (!isset($payload['id'])) {
        throw new RuntimeException("Payload for `$table` is missing its `id` column");
    }
    $payload['branch_id'] = $branchId;

    // Only keep columns that actually exist on the Cloud table — Local
    // and Cloud schemas are meant to match, but this tolerates minor
    // drift (e.g. a column added on Local before a Cloud migration
    // catches up) by silently dropping unknown columns rather than
    // failing the whole batch.
    $knownCols = cloud_table_columns($table);
    $payload = array_intersect_key($payload, array_flip($knownCols));
    if (!$payload) throw new RuntimeException("No matching columns for `$table`");

    $cols    = array_keys($payload);
    $qs      = implode(',', array_fill(0, count($cols), '?'));
    $colList = implode(',', array_map(fn($c) => "`$c`", $cols));
    $updates = implode(', ', array_map(
        fn($c) => "`$c` = VALUES(`$c`)",
        array_filter($cols, fn($c) => $c !== 'id')
    ));

    db_run(
        "INSERT INTO `$table` ($colList) VALUES ($qs)
         ON DUPLICATE KEY UPDATE $updates",
        array_values($payload)
    );
}

function cloud_table_columns(string $table): array {
    static $cache = [];
    if (isset($cache[$table])) return $cache[$table];
    $rows = db_rows(
        "SELECT COLUMN_NAME FROM information_schema.COLUMNS
          WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?",
        [$table]
    );
    return $cache[$table] = array_column($rows, 'COLUMN_NAME');
}

// Idempotent per-table migration: adds `branch_id` (so multiple Local
// Servers/branches can coexist on one Cloud database — see
// SYNC_ARCHITECTURE.md §15) plus a UNIQUE KEY (branch_id, id) that
// upsert_row() relies on for ON DUPLICATE KEY UPDATE to key off.
// With a single branch (branch_id always 1) this behaves exactly like
// a plain `id` primary key; it only matters once branch 2 exists.
function ensure_branch_column(string $table): void {
    static $done = [];
    if (!empty($done[$table])) return;
    $done[$table] = true;

    $hasCol = db_val(
        "SELECT COUNT(*) FROM information_schema.COLUMNS
          WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = 'branch_id'",
        [$table]
    );
    if (!$hasCol) {
        try {
            db_run("ALTER TABLE `$table` ADD COLUMN branch_id INT UNSIGNED NOT NULL DEFAULT 1 FIRST");
        } catch (Throwable $e) { /* best-effort — table may not exist yet on a fresh Cloud DB */ return; }
    }

    $hasKey = db_val(
        "SELECT COUNT(*) FROM information_schema.STATISTICS
          WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND INDEX_NAME = 'uq_branch_record'",
        [$table]
    );
    if (!$hasKey) {
        try {
            db_run("ALTER TABLE `$table` ADD UNIQUE INDEX uq_branch_record (branch_id, id)");
        } catch (Throwable $e) { /* best-effort */ }
    }
}

function verify_signature(string $rawBody, string $signature): bool {
    if (!$signature) return false;
    $expected = hash_hmac('sha256', $rawBody, SYNC_SHARED_SECRET);
    return hash_equals($expected, $signature);
}

function log_cloud_sync(int $queueId, int $branchId, string $table, int $recordId, string $op, string $batchId, bool $success, ?string $error): void {
    db_run(
        "INSERT INTO sync_log (queue_id, branch_id, table_name, record_id, operation_type, batch_id, http_status, success, response_snip)
         VALUES (?,?,?,?,?,?,200,?,?)",
        [$queueId, $branchId, $table, $recordId, $op, $batchId, $success ? 1 : 0, $error !== null ? substr($error, 0, 500) : null]
    );
}

// The Cloud Server needs sync_log + sync_applied even though it never
// writes sync_queue itself (only the Local Server enqueues outbound
// changes — Cloud is receive-only in this architecture, see
// SYNC_ARCHITECTURE.md §1 "Local is master"). ensure_sync_tables()
// from includes/db.php already creates sync_queue/sync_log/sync_status
// for us; this adds the one Cloud-only table.
function ensure_cloud_sync_tables(): void {
    ensure_sync_tables();
    db_run("CREATE TABLE IF NOT EXISTS sync_applied (
        branch_id      INT UNSIGNED NOT NULL,
        table_name     VARCHAR(64)  NOT NULL,
        record_id      BIGINT UNSIGNED NOT NULL,
        last_queue_id  BIGINT UNSIGNED NOT NULL,
        updated_at     TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
        PRIMARY KEY (branch_id, table_name, record_id)
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
}

// ============================================================
//  Dispatch (runs last — everything it calls is defined above)
// ============================================================

// A plain GET/HEAD (what sync/worker.php's reachability probe sends)
// just needs *a* response — no auth, no body required. This keeps
// the "is the Cloud reachable" check cheap and side-effect-free.
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
    echo json_encode(['success' => true, 'service' => 'medicore-sync-receiver']);
    exit;
}

ensure_cloud_sync_tables();

$rawBody   = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_SYNC_SIGNATURE'] ?? '';

if (!verify_signature($rawBody, $signature)) {
    http_response_code(401);
    echo json_encode(['success' => false, 'error' => 'Invalid or missing signature']);
    exit;
}

$data = json_decode($rawBody, true);
if (!is_array($data) || empty($data['rows']) || !is_array($data['rows'])) {
    http_response_code(400);
    echo json_encode(['success' => false, 'error' => 'Malformed batch']);
    exit;
}

$branchId = (int) ($data['branch_id'] ?? 0);
$batchId  = trim($data['batch_id'] ?? '');
if (!$branchId) {
    http_response_code(400);
    echo json_encode(['success' => false, 'error' => 'Missing branch_id']);
    exit;
}

$applied  = [];
$rejected = [];

foreach ($data['rows'] as $row) {
    $queueId  = (int) ($row['queue_id']          ?? 0);
    $table    = (string) ($row['table_name']     ?? '');
    $recordId = (int) ($row['record_id']         ?? 0);
    $op       = (string) ($row['operation_type'] ?? '');
    $payload  = $row['payload'] ?? null;

    try {
        if (!$queueId || !$table || !$recordId || !$op) {
            throw new RuntimeException('Missing required field(s)');
        }
        apply_one_row($branchId, $queueId, $table, $recordId, $op, $payload);
        $applied[] = $queueId;
    } catch (Throwable $e) {
        $rejected[] = [
            'queue_id'       => $queueId,
            'table_name'     => $table,
            'record_id'      => $recordId,
            'operation_type' => $op,
            'error'          => $e->getMessage(),
        ];
        log_cloud_sync($queueId, $branchId, $table, $recordId, $op, $batchId, false, $e->getMessage());
    }
}

echo json_encode([
    'success'  => true,
    'batch_id' => $batchId,
    'applied'  => $applied,
    'rejected' => $rejected,
]);
exit;
