<?php
declare(strict_types=1);

require_once __DIR__ . '/AzureSearchAdmin.php';
require_once __DIR__ . '/FreeTier.php';

/**
 * Build Your Own Case — per-user private RAG corpus.
 *
 * Storage layout:
 *   - PDFs: /home/dobetternorge/uploads/case_{user_id}/{doc_id}.pdf  (chloe filesystem)
 *   - Vectors: Azure AI Search index "case-{user_id}" (hybrid BM25 + vector + nb.microsoft analyzer)
 *   - Metadata: MySQL case_documents rows
 *
 * Family plan note: members share owner's caveau_client_id but each query is still scoped
 * to the OWNER's user_id (and thus their index). Family members' UI-bound user_id resolves
 * to owner_user_id via caseResolveClientId.
 */
final class CaseStore
{
    /** Storage root on chloe — override via env if needed. */
    public static function storageRoot(): string
    {
        $env = getenv('DBN_CASE_STORAGE_ROOT');
        return rtrim($env !== false && $env !== '' ? $env : '/home/dobetternorge/uploads', '/');
    }

    /** Resolve the effective case-owner user_id for a given session user.
     *  If the user is a family-plan MEMBER, returns the OWNER's user_id (shared corpus).
     *  If the user is an owner OR has no seat record, returns their own user_id.
     */
    public static function caseResolveClientId(int $userId): int
    {
        $db = dbnmDb();
        $stmt = $db->prepare(
            'SELECT owner_user_id FROM case_seats
             WHERE member_user_id = ? AND accepted_at IS NOT NULL AND revoked_at IS NULL
             LIMIT 1'
        );
        $stmt->execute([$userId]);
        $ownerId = (int)($stmt->fetchColumn() ?: 0);
        return $ownerId > 0 ? $ownerId : $userId;
    }

    /** Ensure storage dir + Azure index exist for a user. Idempotent. */
    public static function caseProvisionUser(int $userId): array
    {
        $rootDir = self::storageRoot() . '/case_' . $userId;
        if (!is_dir($rootDir)) {
            // 0750: owner rwx, group rx, world none
            @mkdir($rootDir, 0750, true);
        }
        $indexName = '';
        try {
            $admin = new AzureSearchAdmin();
            $indexName = $admin->ensureUserIndex($userId);
        } catch (Throwable $e) {
            error_log('[CaseStore::caseProvisionUser] index create failed: ' . $e->getMessage());
        }
        return ['storage_path' => $rootDir, 'index_name' => $indexName];
    }

    /**
     * Register an uploaded file in DB and return the doc row.
     * Enforces tier-based storage quota.
     */
    public static function registerUpload(int $userId, string $filename, string $tempPath, int $sizeBytes): array
    {
        // Quota check
        $detail = FreeTier::balanceDetail($userId);
        $quota = (int)$detail['storage_quota_bytes'];
        $used  = (int)$detail['storage_used_bytes'];
        if ($quota === 0) {
            throw new RuntimeException('Min Sak er ikke tilgjengelig på gratis-nivå. Oppgrader for å laste opp dokumenter.');
        }
        if ($used + $sizeBytes > $quota) {
            $remainMb = max(0, ($quota - $used) / 1048576);
            throw new RuntimeException(sprintf('Du har %.1f MB lagring igjen, men filen er %.1f MB.', $remainMb, $sizeBytes / 1048576));
        }

        // Provision (idempotent)
        $bundle = self::caseProvisionUser($userId);
        $dir = $bundle['storage_path'];

        // Sanitize filename
        $safeName = preg_replace('/[^A-Za-z0-9._\-]/', '_', $filename);
        $safeName = mb_substr((string)$safeName, 0, 100);

        $db = dbnmDb();
        $db->prepare(
            'INSERT INTO case_documents
                (user_id, filename, storage_path, size_bytes, ocr_status, qdrant_collection, azure_index_name, uploaded_at)
             VALUES (?, ?, ?, ?, ?, ?, ?, NOW())'
        )->execute([
            $userId, $safeName, '', $sizeBytes, 'pending',
            'case_user_' . $userId,
            AzureSearchAdmin::indexName($userId),
        ]);
        $docId = (int)$db->lastInsertId();

        $finalPath = $dir . '/' . $docId . '.pdf';
        if (!@rename($tempPath, $finalPath)) {
            // Fallback: copy + unlink
            if (!@copy($tempPath, $finalPath)) {
                $db->prepare('DELETE FROM case_documents WHERE id = ?')->execute([$docId]);
                throw new RuntimeException('Kunne ikke lagre filen på serveren.');
            }
            @unlink($tempPath);
        }
        @chmod($finalPath, 0640);

        // Save final path + bump storage usage
        $db->prepare('UPDATE case_documents SET storage_path = ? WHERE id = ?')
           ->execute([$finalPath, $docId]);
        $db->prepare('UPDATE user_tool_credits SET storage_used_bytes = storage_used_bytes + ? WHERE user_id = ?')
           ->execute([$sizeBytes, $userId]);

        return [
            'doc_id' => $docId,
            'filename' => $safeName,
            'storage_path' => $finalPath,
            'size_bytes' => $sizeBytes,
        ];
    }

    /** Notify n8n that a new doc is ready for OCR + indexing. */
    public static function caseEnqueueIngest(int $docId, int $userId): bool
    {
        $webhookUrl = getenv('N8N_CASE_INGEST_WEBHOOK') ?: '';
        if ($webhookUrl === '') {
            error_log('[CaseStore] N8N_CASE_INGEST_WEBHOOK not configured — leaving doc ' . $docId . ' as pending');
            return false;
        }
        $payload = json_encode([
            'doc_id' => $docId,
            'user_id' => $userId,
            'callback_url' => 'https://tools.dobetternorge.no/api/case/ingest-callback.php',
        ], JSON_UNESCAPED_UNICODE);

        $ch = curl_init($webhookUrl);
        curl_setopt_array($ch, [
            CURLOPT_POST => true,
            CURLOPT_POSTFIELDS => $payload,
            CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_TIMEOUT => 5,
        ]);
        curl_exec($ch);
        $errno = curl_errno($ch);
        $status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
        curl_close($ch);
        return $errno === 0 && $status >= 200 && $status < 300;
    }

    /**
     * Hybrid search across the user's case.
     * Embeds the query via LiteLLM (azure-text-embedding-3-small) and hits the per-user Azure Search index.
     *
     * CRITICAL: $userId here must be the EFFECTIVE owner_user_id (resolved via caseResolveClientId).
     * The Azure index is scoped to this user_id at the INDEX NAME level — cross-user leak is structurally
     * impossible: index "case-100" cannot return rows from index "case-200".
     */
    public static function caseHybridSearch(int $effectiveOwnerUserId, string $query, int $k = 5): array
    {
        if ($effectiveOwnerUserId <= 0 || trim($query) === '') {
            return [];
        }
        try {
            $vector = self::embedQuery($query);
            if (empty($vector)) {
                return [];
            }
            $admin = new AzureSearchAdmin();
            $resp = $admin->hybridSearch($effectiveOwnerUserId, $query, $vector, $k);
            $hits = [];
            foreach (($resp['value'] ?? []) as $hit) {
                $hits[] = [
                    'chunk_text' => (string)($hit['chunk_text'] ?? ''),
                    'filename'   => (string)($hit['filename'] ?? ''),
                    'page'       => (int)($hit['page'] ?? 0),
                    'doc_id'     => (int)($hit['doc_id'] ?? 0),
                    'doc_type'   => (string)($hit['doc_type'] ?? ''),
                    'score'      => (float)($hit['@search.score'] ?? 0),
                    'reranker_score' => (float)($hit['@search.rerankerScore'] ?? 0),
                ];
            }
            return $hits;
        } catch (Throwable $e) {
            error_log('[CaseStore::caseHybridSearch] failed: ' . $e->getMessage());
            return [];
        }
    }

    /** Embed a string via LiteLLM (azure-text-embedding-3-small). Returns float[] of dim 1536, or []. */
    public static function embedQuery(string $text): array
    {
        $base = getenv('LITELLM_BASE_URL') ?: 'http://10.0.1.10:4000';
        $key  = getenv('LITELLM_API_KEY')  ?: 'sk-bnl-litellm-26xR9mK4qvN3wL8sTj7pB2d';
        $payload = json_encode([
            'model' => 'azure-text-embedding-3-small',
            'input' => mb_substr($text, 0, 8000),
        ], JSON_UNESCAPED_UNICODE);

        $ch = curl_init($base . '/v1/embeddings');
        curl_setopt_array($ch, [
            CURLOPT_POST => true,
            CURLOPT_POSTFIELDS => $payload,
            CURLOPT_HTTPHEADER => [
                'Authorization: Bearer ' . $key,
                'Content-Type: application/json',
            ],
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_TIMEOUT => 15,
        ]);
        $raw = curl_exec($ch);
        $status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
        curl_close($ch);
        if ($status !== 200 || !is_string($raw)) {
            return [];
        }
        $data = json_decode($raw, true);
        $vec = $data['data'][0]['embedding'] ?? null;
        return is_array($vec) ? array_map('floatval', $vec) : [];
    }

    /** Format chunks for injection into an agent's system prompt. */
    public static function formatChunksForPrompt(array $chunks): string
    {
        if (empty($chunks)) return '';
        $out = "\n\n## Brukerens egne dokumenter (private sak):\n";
        foreach ($chunks as $i => $c) {
            $out .= sprintf(
                "\n[%d] %s · side %d%s\n%s\n",
                $i + 1,
                $c['filename'],
                $c['page'],
                $c['doc_type'] !== '' ? ' · ' . $c['doc_type'] : '',
                mb_substr($c['chunk_text'], 0, 1500)
            );
        }
        $out .= "\n— slutt på brukerens dokumenter —\n";
        return $out;
    }

    /** Soft-delete a doc + remove vectors from Azure index. */
    public static function deleteDocument(int $userId, int $docId): bool
    {
        $db = dbnmDb();
        $stmt = $db->prepare('SELECT id, storage_path, size_bytes FROM case_documents WHERE id = ? AND user_id = ? AND deleted_at IS NULL LIMIT 1');
        $stmt->execute([$docId, $userId]);
        $doc = $stmt->fetch(PDO::FETCH_ASSOC);
        if (!$doc) {
            return false;
        }
        // Remove from Azure index
        try {
            $admin = new AzureSearchAdmin();
            $admin->deleteDoc($userId, $docId);
        } catch (Throwable $e) {
            error_log('[CaseStore::deleteDocument] azure delete: ' . $e->getMessage());
        }
        // Mark deleted in DB
        $db->prepare('UPDATE case_documents SET deleted_at = NOW() WHERE id = ?')->execute([$docId]);
        // Refund storage
        $db->prepare('UPDATE user_tool_credits SET storage_used_bytes = GREATEST(0, storage_used_bytes - ?) WHERE user_id = ?')
           ->execute([(int)$doc['size_bytes'], $userId]);
        // Remove file from disk
        if (!empty($doc['storage_path']) && is_file($doc['storage_path'])) {
            @unlink($doc['storage_path']);
        }
        return true;
    }

    /** Return all docs for a user (excluding deleted). */
    public static function listDocs(int $userId): array
    {
        $db = dbnmDb();
        $stmt = $db->prepare(
            'SELECT id, filename, size_bytes, page_count, doc_type, detected_date, ocr_status, ocr_error, uploaded_at, indexed_at
             FROM case_documents WHERE user_id = ? AND deleted_at IS NULL ORDER BY uploaded_at DESC'
        );
        $stmt->execute([$userId]);
        return $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
    }
}
