<?php
/**
 * Chunked upload + ingest API (JSON).  Lets the ops team upload very large
 * Collection Raw / Facility Mapping files entirely from the browser, with a
 * live progress %, without ever hitting PHP's upload_max_filesize / post_max_size
 * (each chunk is tiny) and without touching cPanel.
 *
 * POST actions (multipart/form-data):
 *   action=chunk     token, index, total, kind, _csrf, chunk(blob)
 *   action=finalize  token, kind, report_id, filename, confirm, _csrf
 *
 * Always responds with JSON: {ok:true,...} or {ok:false,error:"..."}.
 */
// Buffer ALL output from the very start. config.php runs migrations + registers handlers at load time, and on a
// disk-stressed server that can emit stray output BEFORE our session starts (session_start lives in require_login,
// below) — which would commit headers and fatally break session_start(). Buffering keeps headers uncommitted; we
// discard any stray bytes right after login so the JSON body stays clean.
ob_start();
require_once __DIR__ . '/../includes/config.php';
require_once __DIR__ . '/../includes/collection-engine.php';

header('Content-Type: application/json; charset=utf-8');

/* JSON helper */
function jout(array $data, int $code = 200): never {
    http_response_code($code);
    echo json_encode($data);
    exit;
}

$me = require_login('internal');
if (ob_get_level() > 0) @ob_clean();   // session is up + headers still uncommitted → drop any stray load-time output so JSON is clean

if ($_SERVER['REQUEST_METHOD'] !== 'POST') jout(['ok' => false, 'error' => 'POST only'], 405);
if (!csrf_check())                          jout(['ok' => false, 'error' => 'Session expired — refresh the page and try again.'], 419);

$action   = (string) ($_POST['action'] ?? '');
$kind     = (string) ($_POST['kind'] ?? '');                 // 'collection' | 'facility-mapping' | 'deductible-mapping'
$reportId = (int) ($_POST['report_id'] ?? 0);
$token    = preg_replace('/[^a-f0-9]/', '', (string) ($_POST['token'] ?? ''));   // hex only → no path traversal

if (!in_array($kind, ['collection', 'facility-mapping', 'deductible-mapping'], true)) jout(['ok' => false, 'error' => 'Bad upload kind.'], 400);
// The "clear" action is not file-bound — it just wipes data for a report — so it doesn't
// carry a meaningful upload token.  Every other action does require a token.
if ($action !== 'clear' && strlen($token) < 8) jout(['ok' => false, 'error' => 'Bad upload token.'], 400);
if ($reportId <= 0)      jout(['ok' => false, 'error' => 'A report context is required.'], 400);
if (!user_can_view_report((int) $me['id'], $reportId)) jout(['ok' => false, 'error' => "You don't have access to that report."], 403);

$tmpDir = __DIR__ . '/../storage/uploads/tmp';
if (!is_dir($tmpDir)) @mkdir($tmpDir, 0755, true);
$partPath = $tmpDir . '/' . $token . '.part';

/* ---------- receive one chunk ---------- */
if ($action === 'chunk') {
    $index = (int) ($_POST['index'] ?? -1);
    if ($index < 0) jout(['ok' => false, 'error' => 'Bad chunk index.'], 400);
    if (!isset($_FILES['chunk']) || $_FILES['chunk']['error'] !== UPLOAD_ERR_OK) {
        jout(['ok' => false, 'error' => 'Chunk upload failed (error ' . (int) ($_FILES['chunk']['error'] ?? -1) . ').'], 400);
    }
    // index 0 starts a fresh file; later chunks append in order
    $out = @fopen($partPath, $index === 0 ? 'wb' : 'ab');
    if (!$out) jout(['ok' => false, 'error' => 'Cannot open temp file (check storage/uploads permissions).'], 500);
    $in = @fopen($_FILES['chunk']['tmp_name'], 'rb');
    if (!$in) { fclose($out); jout(['ok' => false, 'error' => 'Cannot read chunk.'], 500); }
    stream_copy_to_stream($in, $out);
    fclose($in);
    fclose($out);
    jout(['ok' => true, 'index' => $index]);
}

/* ---------- assemble the uploaded chunks into a stored file (NO ingest) ----------
   Used by the two-step flow: upload both files to the server first, then a separate
   "Process" action runs the mapping + collection import on the stored files. */
if ($action === 'store') {
    if (!is_file($partPath)) jout(['ok' => false, 'error' => 'Upload not found — please retry.'], 400);
    $origName = (string) ($_POST['filename'] ?? 'upload.csv');
    $ext = strtolower(pathinfo($origName, PATHINFO_EXTENSION));
    if (!in_array($ext, ['csv', 'tsv'], true)) {
        @unlink($partPath);
        jout(['ok' => false, 'error' => "Only .csv / .tsv accepted (got .$ext)."], 400);
    }
    $finalDir = __DIR__ . '/../storage/uploads/' . $kind;
    if (!is_dir($finalDir)) @mkdir($finalDir, 0755, true);
    $finalPath = $finalDir . '/' . date('YmdHis') . '_' . $token . '.' . $ext;
    if (!@rename($partPath, $finalPath)) {
        @unlink($partPath);
        jout(['ok' => false, 'error' => 'Could not save the uploaded file.'], 500);
    }
    // Track it as "pending" so it survives a page reload and can be processed later.
    try {
        db()->prepare("INSERT INTO pending_uploads (report_id, kind, token, file_name, orig_name, file_size, uploaded_by, status, created_at)
                       VALUES (?,?,?,?,?,?,?, 'pending', NOW())")
            ->execute([$reportId, $kind, $token, basename($finalPath), $origName, @filesize($finalPath) ?: null, (int) $me['id']]);
    } catch (Throwable $e) { /* non-fatal: table may be absent on a brand-new deploy; the file is still stored */ }
    jout(['ok' => true, 'stored' => true, 'token' => $token, 'ext' => $ext]);
}

/* ---------- discard a stored-but-unprocessed file ---------- */
if ($action === 'discard') {
    try {
        $row = db()->prepare("SELECT file_name, kind FROM pending_uploads WHERE report_id = ? AND token = ? AND status = 'pending' LIMIT 1");
        $row->execute([$reportId, $token]);
        if ($r = $row->fetch()) {
            // Map the stored kind back to its own upload dir (store wrote to storage/uploads/<kind>).
            // Whitelisted so an unexpected value can never point unlink() outside a known dir.
            $kindDir = in_array($r['kind'], ['collection', 'facility-mapping', 'deductible-mapping'], true) ? $r['kind'] : 'facility-mapping';
            $fp = __DIR__ . '/../storage/uploads/' . $kindDir . '/' . basename($r['file_name']);
            if (is_file($fp)) @unlink($fp);
            db()->prepare("DELETE FROM pending_uploads WHERE report_id = ? AND token = ? AND status = 'pending'")->execute([$reportId, $token]);
        }
    } catch (Throwable $e) { /* ignore */ }
    jout(['ok' => true]);
}

/* ---------- reassemble + ingest ---------- */
if ($action === 'finalize') {
    if (!is_file($partPath)) jout(['ok' => false, 'error' => 'Upload not found — please retry.'], 400);

    $origName = (string) ($_POST['filename'] ?? 'upload.csv');
    $ext = strtolower(pathinfo($origName, PATHINFO_EXTENSION));
    if (!in_array($ext, ['csv', 'tsv'], true)) {
        @unlink($partPath);
        jout(['ok' => false, 'error' => "Only .csv / .tsv accepted (got .$ext)."], 400);
    }

    // Collection replaces all data — require an explicit confirm flag.
    if ($kind === 'collection' && (string) ($_POST['confirm'] ?? '') !== 'REPLACE') {
        @unlink($partPath);
        jout(['ok' => false, 'error' => 'Replace not confirmed.'], 400);
    }

    $finalDir = __DIR__ . '/../storage/uploads/' . $kind;
    if (!is_dir($finalDir)) @mkdir($finalDir, 0755, true);
    $finalPath = $finalDir . '/' . date('YmdHis') . '_' . $token . '.' . $ext;
    if (!@rename($partPath, $finalPath)) {
        @unlink($partPath);
        jout(['ok' => false, 'error' => 'Could not save the assembled file.'], 500);
    }

    // A 190k-row ingest runs for minutes — far longer than a shared host's gateway
    // timeout, which would hand the browser an HTTP 503 even though PHP keeps working.
    // So we DETACH: keep running if the client/proxy disconnects, release the session
    // lock (so the page's status-poll requests aren't blocked behind this one), and —
    // where the SAPI allows — flush an "accepted" reply now and finish in the background.
    // The browser then polls action=status (by token) for the real outcome.
    @set_time_limit(0);
    @ini_set('memory_limit', '1G');
    ignore_user_abort(true);
    if (function_exists('session_write_close')) @session_write_close();

    $detached = false;
    if (function_exists('fastcgi_finish_request') || function_exists('litespeed_finish_request')) {
        echo json_encode(['ok' => true, 'processing' => true, 'token' => $token,
                          'redirect' => url('/admin/collection.php?id=' . $reportId)]);
        while (ob_get_level() > 0) { @ob_end_flush(); }
        @flush();
        if (function_exists('fastcgi_finish_request')) { fastcgi_finish_request(); }
        elseif (function_exists('litespeed_finish_request')) { litespeed_finish_request(); }
        $detached = true;
    }

    try {
        $eng = new CollectionEngine();
        if ($kind === 'collection') {
            $res = $eng->ingestCollection($finalPath, (int) $me['id'], $reportId);
            audit_log('collection.upload', 'collection', null, null, $res);
        } elseif ($kind === 'deductible-mapping') {
            $deferRefresh = ((string) ($_POST['defer'] ?? '') === '1');
            $res = $eng->ingestDeductibleMapping($finalPath, (int) $me['id'], $reportId, $deferRefresh);
            audit_log('deductible_mapping.upload', 'deductible_mapping', null, null, $res);
        } else {
            // Combined upload (mapping + collection on one page) sets defer=1 so the
            // mapping import skips re-deriving collection + rebuilding the cache — the
            // collection import that follows does both once.
            $deferRefresh = ((string) ($_POST['defer'] ?? '') === '1');
            $res = $eng->ingestFacilityMapping($finalPath, (int) $me['id'], $reportId, $deferRefresh);
            audit_log('facility_mapping.upload', 'facility_mapping', null, null, $res);
        }
        if (!$detached) {
            jout([
                'ok'     => true,
                'valid'  => (int) ($res['valid'] ?? 0),
                'errors' => (int) ($res['errors'] ?? 0),
                'total'  => (int) ($res['total'] ?? 0),
                'redirect' => url('/admin/collection.php?id=' . $reportId),
            ]);
        }
        exit;   // detached: the upload row is marked completed; the browser polls action=status
    } catch (Throwable $t) {
        // The engine already stamped the failure on the upload row (failUploadRecord),
        // so a polling client surfaces it.  Only the inline path can still reply here.
        log_error_db('Ingest failed: ' . $t->getMessage() . "\n" . $t->getTraceAsString(),
                     'ingest', ['report_id' => $reportId, 'kind' => $kind]);
        if (!$detached) jout(['ok' => false, 'error' => 'Ingest failed: ' . $t->getMessage()], 500);
        exit;
    }
}

/* ---------- run the pipeline on already-uploaded files (mapping first, then collection) ----------
   Step 2 of the two-step flow.  Files were stored by action=store; we locate them by token,
   ingest the mapping (deferring its recompute) then the collection, detached so a long run
   can't trigger a gateway 503.  The browser polls action=status (by the collection token). */
if ($action === 'process') {
    // Append mode skips the destructive "DELETE FROM collection_raw" step.  Browser sends
    // append=1 to opt in; the confirm word is also relaxed so the user types ADD/APPEND
    // instead of REPLACE (since nothing's being destroyed).
    $appendMode = ((string) ($_POST['append'] ?? '') === '1');
    $needConfirm = $appendMode ? 'APPEND' : 'REPLACE';
    $given       = strtoupper(trim((string) ($_POST['confirm'] ?? '')));
    if ($given !== $needConfirm) jout(['ok' => false, 'error' => "Type {$needConfirm} to confirm."], 400);
    $collToken = $token;   // validated hex (>=8) at the top — the collection file's token
    $mapToken  = preg_replace('/[^a-f0-9]/', '', (string) ($_POST['map_token'] ?? ''));

    // Locate stored files by token (hex-only → safe glob, no path traversal).
    $findStored = function (string $kindDir, string $tok): ?string {
        if (strlen($tok) < 8) return null;
        $hits = glob(__DIR__ . '/../storage/uploads/' . $kindDir . '/*_' . $tok . '.*') ?: [];
        return $hits[0] ?? null;
    };
    $collPath = $findStored('collection', $collToken);
    if (!$collPath) jout(['ok' => false, 'error' => 'Collection file not found on the server — please re-upload.'], 400);
    $mapPath = $mapToken !== '' ? $findStored('facility-mapping', $mapToken) : null;

    @set_time_limit(0);
    @ini_set('memory_limit', '1G');
    ignore_user_abort(true);
    if (function_exists('session_write_close')) @session_write_close();

    $detached = false;
    if (function_exists('fastcgi_finish_request') || function_exists('litespeed_finish_request')) {
        echo json_encode(['ok' => true, 'processing' => true, 'token' => $collToken,
                          'redirect' => url('/admin/collection.php?id=' . $reportId)]);
        while (ob_get_level() > 0) { @ob_end_flush(); }
        @flush();
        if (function_exists('fastcgi_finish_request')) { fastcgi_finish_request(); }
        elseif (function_exists('litespeed_finish_request')) { litespeed_finish_request(); }
        $detached = true;
    }

    try {
        $eng = new CollectionEngine();
        if ($mapPath) {
            $mres = $eng->ingestFacilityMapping($mapPath, (int) $me['id'], $reportId, true);  // defer collection recompute
            audit_log('facility_mapping.upload', 'facility_mapping', null, null, $mres);
        }
        $res = $eng->ingestCollection($collPath, (int) $me['id'], $reportId, $appendMode);
        audit_log($appendMode ? 'collection.append' : 'collection.upload', 'collection', null, null, $res);
        // Files consumed → drop them from the pending list.
        try {
            $mk = db()->prepare("UPDATE pending_uploads SET status='processed', processed_at=NOW() WHERE report_id = ? AND token = ? AND status='pending'");
            $mk->execute([$reportId, $collToken]);
            if ($mapToken !== '') $mk->execute([$reportId, $mapToken]);
        } catch (Throwable $e) { /* ignore */ }
        if (!$detached) {
            jout(['ok' => true, 'valid' => (int) ($res['valid'] ?? 0), 'errors' => (int) ($res['errors'] ?? 0),
                  'total' => (int) ($res['total'] ?? 0), 'redirect' => url('/admin/collection.php?id=' . $reportId)]);
        }
        exit;
    } catch (Throwable $t) {
        log_error_db('Process (two-step) failed: ' . $t->getMessage() . "\n" . $t->getTraceAsString(),
                     'process', ['report_id' => $reportId, 'coll_token' => $collToken, 'map_token' => $mapToken]);
        if (!$detached) jout(['ok' => false, 'error' => 'Processing failed: ' . $t->getMessage()], 500);
        exit;
    }
}

/* ---------- wipe ALL data for this report (collection + stage + cache + history) ----------
   Facility mapping is intentionally preserved.  Cheap (a few DELETEs); inline reply. */
if ($action === 'clear') {
    if (strtoupper(trim((string) ($_POST['confirm'] ?? ''))) !== 'CLEAR') {
        jout(['ok' => false, 'error' => 'Type CLEAR to confirm.'], 400);
    }
    // Chunked delete of 200k+ rows can take a minute or two on a throttled host —
    // make sure neither PHP nor the session lock kills it.
    @set_time_limit(0);
    @ini_set('memory_limit', '512M');
    ignore_user_abort(true);
    if (function_exists('session_write_close')) @session_write_close();

    /* Don't start a wipe while an upload for this report is still being ingested. Both are heavy writers on the
     * same collection_raw rows, so running them together produced exactly what the error log showed:
     * 1213 deadlock / 1205 lock-wait-timeout, and a half-done clear. The engine now RETRIES transient lock
     * errors, so an overlap is survivable — but refusing up front with a plain-English reason is far better
     * than letting the user watch it grind and then fail.
     * Time-bounded on purpose: an abandoned upload leaves its row 'pending' forever, and without the window a
     * single crashed upload would block every future clear on this report. Fails OPEN (any error here → allow
     * the clear) because this is a courtesy check, not a security control. */
    try {
        $busy = db()->prepare("SELECT COUNT(*) FROM pending_uploads
                                WHERE report_id = ? AND status = 'pending'
                                  AND created_at > DATE_SUB(NOW(), INTERVAL 15 MINUTE)");
        $busy->execute([$reportId]);
        if ((int) $busy->fetchColumn() > 0) {
            jout(['ok' => false, 'error' => 'An upload is still being processed for this report. '
                . 'Wait for it to finish (watch the progress bar / refresh in a minute), then clear again.'], 409);
        }
    } catch (Throwable $e) { /* table absent or unreadable → skip the courtesy check, proceed with the clear */ }

    try {
        $eng = new CollectionEngine();
        $counts = $eng->clearReportData($reportId);
        audit_log('collection.clear', 'collection', null, null, $counts);
        jout(['ok' => true, 'counts' => $counts, 'redirect' => url('/admin/upload-collection.php?id=' . $reportId)]);
    } catch (Throwable $t) {
        log_error_db('Clear data failed: ' . $t->getMessage(), 'clear', ['report_id' => $reportId]);
        jout(['ok' => false, 'error' => 'Clear failed: ' . $t->getMessage()], 500);
    }
}

/* ---------- poll ingest status (the browser calls this after finalize) ---------- */
if ($action === 'status') {
    $kindCol = ($kind === 'collection') ? 'collection' : (($kind === 'deductible-mapping') ? 'deductible_mapping' : 'facility_mapping');
    $st = db()->prepare("SELECT status, total_rows, valid_rows, error_rows, note
                           FROM collection_uploads
                          WHERE report_id = ? AND kind = ? AND file_name LIKE CONCAT('%', ?, '%')
                          ORDER BY id DESC LIMIT 1");
    $st->execute([$reportId, $kindCol, $token]);
    $row = $st->fetch();
    if (!$row) jout(['ok' => true, 'status' => 'pending']);   // upload row not created yet
    jout([
        'ok'       => true,
        'status'   => (string) $row['status'],          // processing | completed | failed
        'valid'    => (int) $row['valid_rows'],
        'errors'   => (int) $row['error_rows'],
        'total'    => (int) $row['total_rows'],
        'note'     => (string) ($row['note'] ?? ''),
        'redirect' => url('/admin/collection.php?id=' . $reportId),
    ]);
}

jout(['ok' => false, 'error' => 'Unknown action.'], 400);
