<?php

class DataSource {
    private $pdo;
    private $uploadDir;
    private $columnExistenceCache = [];
    private const DEFAULT_FILE_RULES = [
        'extensions' => ['csv', 'json', 'pdf', 'xls', 'xlsx'],
        'mimes' => [
            'text/csv',
            'text/plain',
            'application/json',
            'application/pdf',
            'application/vnd.ms-excel',
            'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
        ],
        'description' => 'CSV, JSON, PDF, XLS, XLSX',
    ];
    private const TYPE_SPECIFIC_RULES = [
        'csv' => [
            'extensions' => ['csv'],
            'mimes' => ['text/csv', 'text/plain', 'application/vnd.ms-excel'],
            'description' => 'CSV files (.csv)',
        ],
        'json' => [
            'extensions' => ['json'],
            'mimes' => ['application/json', 'text/json', 'text/plain'],
            'description' => 'JSON files (.json)',
        ],
        'pdf' => [
            'extensions' => ['pdf'],
            'mimes' => ['application/pdf'],
            'description' => 'PDF documents (.pdf)',
        ],
        'xls' => [
            'extensions' => ['xls'],
            'mimes' => ['application/vnd.ms-excel'],
            'description' => 'Excel 97-2003 (.xls)',
        ],
        'xlsx' => [
            'extensions' => ['xlsx'],
            'mimes' => ['application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'],
            'description' => 'Excel (.xlsx)',
        ],
        'dta' => [
            'extensions' => ['dta'],
            'mimes' => [
                'application/x-stata',
                'application/stata',
                'application/octet-stream',
            ],
            'description' => 'Stata dataset (.dta)',
        ],
        'stata' => [
            'extensions' => ['dta'],
            'mimes' => [
                'application/x-stata',
                'application/stata',
                'application/octet-stream',
            ],
            'description' => 'Stata dataset (.dta)',
        ],
        'sav' => [
            'extensions' => ['sav'],
            'mimes' => [
                'application/octet-stream',
                'application/vnd.spss.sav',
            ],
            'description' => 'SPSS dataset (.sav)',
        ],
    ];

    public function __construct(PDO $pdo) {
        $this->pdo = $pdo;

        // Correctly define the absolute path from the project root.
        // __DIR__ points to the 'classes' directory.
        // dirname(__DIR__) moves up one level to 'dsp26072025'.
        $projectRoot = dirname(__DIR__);
        $this->uploadDir = $projectRoot . '/uploads/datasources/';

        // Ensure upload directory exists
        if (!is_dir($this->uploadDir)) {
            if (!mkdir($this->uploadDir, 0777, true)) {
                // If it still fails, there is a deeper permissions issue
                die("Error: Failed to create directory. Please check permissions for " . $this->uploadDir);
            }
        }
    }

    // Public getter for upload directory (needed for file deletion)
    public function getUploadDir(): string {
        return $this->uploadDir;
    }

    // --- Data Source Management (dsps_tbl_datasource) ---

    /**
     * Adds a new data source.
     *
     * @param int $fkdspstds_id FK to dsps_tbl_typedatasource.
     * @param int $fkdspscate_id FK to dsps_tbl_dspscategory.
     * @param int $fkisp_id_of FK to ist_tbl_people (Data Owner).
     * @param string|null $filename Primary file path/name or API endpoint URL.
     * @param string $title_en English title.
     * @param string|null $title_kh Khmer title.
     * @param string|null $description Description.
     * @param string $status Status ('Active', 'Inactive', 'Pending Review', 'Published').
     * @param int $reg_by User ID who registered it.
     * @param string|null $filename1 Optional secondary file path.
     * @param string|null $filename2 Optional tertiary file path.
     * @param string|null $filename3 Optional quaternary file path.
     * @return bool True on success.
     * @throws Exception If a database error occurs.
     */
    public function addDataSource(
        int $fkdspstds_id,
        int $fkdspscate_id,
        int $fkisp_id_of,
        ?string $filename,
        string $title_en,
        ?string $title_kh,
        ?string $description,
        string $status,
        int $reg_by,
        ?string $filename1 = null,
        ?string $filename2 = null,
        ?string $filename3 = null
    ): bool {
        // Determine public_date based on status. If 'Published', set to current date, otherwise NULL.
        $publicDateSql = ($status === 'Published') ? "CURRENT_DATE" : "NULL";

        $sql = "INSERT INTO dsps_tbl_datasource (fkdspstds_id, fkdspscate_id, fkisp_id_of, dspsds_filename,
                                                dspsds_title_en, dspsds_title_kh, dspsds_description,
                                                dspsds_status, dspsds_reg_by, dspsds_public_date,
                                                dspsds_filename1, dspsds_filename2, dspsds_filename3)
                VALUES (:fkdspstds_id, :fkdspscate_id, :fkisp_id_of, :filename,
                        :title_en, :title_kh, :description, :status, :reg_by,
                        " . $publicDateSql . ", :filename1, :filename2, :filename3)"; // Directly inject CURRENT_DATE or NULL
        try {
            $stmt = $this->pdo->prepare($sql);
            $stmt->bindParam(':fkdspstds_id', $fkdspstds_id, PDO::PARAM_INT);
            $stmt->bindParam(':fkdspscate_id', $fkdspscate_id, PDO::PARAM_INT);
            $stmt->bindParam(':fkisp_id_of', $fkisp_id_of, PDO::PARAM_INT);
            $stmt->bindParam(':filename', $filename);
            $stmt->bindParam(':title_en', $title_en);
            $stmt->bindParam(':title_kh', $title_kh);
            $stmt->bindParam(':description', $description);
            $stmt->bindParam(':status', $status);
            $stmt->bindParam(':reg_by', $reg_by, PDO::PARAM_INT);
            $stmt->bindParam(':filename1', $filename1);
            $stmt->bindParam(':filename2', $filename2);
            $stmt->bindParam(':filename3', $filename3);
            return $stmt->execute();
        } catch (PDOException $e) {
            error_log("Error adding data source: " . $e->getMessage());
            throw new Exception("Could not add data source. Please try again later.");
        }
    }

    /**
     * Updates an existing data source.
     *
     * @param int $id ID of the data source to update.
     * @param int $fkdspstds_id FK to dsps_tbl_typedatasource.
     * @param int $fkdspscate_id FK to dsps_tbl_dspscategory.
     * @param int $fkisp_id_of FK to ist_tbl_people (Data Owner).
     * @param string|null $filename File path/name or API endpoint URL.
     * @param string $title_en English title.
     * @param string|null $title_kh Khmer title.
     * @param string|null $description Description.
     * @param string $status Status ('Active', 'Inactive', 'Pending Review', 'Published').
     * @param int $mod_by User ID who modified it.
     * @param string|null $filename1 Optional secondary file path.
     * @param string|null $filename2 Optional tertiary file path.
     * @param string|null $filename3 Optional quaternary file path.
     * @return bool True on success.
     * @throws Exception If a database error occurs.
     */
    public function updateDataSource(
        int $id,
        int $fkdspstds_id,
        int $fkdspscate_id,
        int $fkisp_id_of,
        ?string $filename,
        string $title_en,
        ?string $title_kh,
        ?string $description,
        string $status,
        int $mod_by,
        ?string $filename1 = null,
        ?string $filename2 = null,
        ?string $filename3 = null
    ): bool {
        // Check current status to decide on dspsds_public_date update
        $currentDataSource = $this->getDataSourceById($id);
        $publicDateUpdate = "";
        if ($status === 'Published' && ($currentDataSource['dspsds_status'] !== 'Published' || empty($currentDataSource['dspsds_public_date']))) {
            $publicDateUpdate = ", dspsds_public_date = CURRENT_DATE";
        } elseif ($status !== 'Published' && !empty($currentDataSource['dspsds_public_date'])) {
            $publicDateUpdate = ", dspsds_public_date = NULL";
        }

        $sql = "UPDATE dsps_tbl_datasource
                SET fkdspstds_id = :fkdspstds_id, fkdspscate_id = :fkdspscate_id, fkisp_id_of = :fkisp_id_of,
                    dspsds_filename = :filename, dspsds_title_en = :title_en, dspsds_title_kh = :title_kh,
                    dspsds_description = :description, dspsds_status = :status,
                    dspsds_filename1 = :filename1, dspsds_filename2 = :filename2, dspsds_filename3 = :filename3,
                    dspsds_mod_datetime = CURRENT_TIMESTAMP, dspsds_reg_by = :mod_by
                    {$publicDateUpdate}
                WHERE pkdspsds_id = :id";
        try {
            $stmt = $this->pdo->prepare($sql);
            $stmt->bindParam(':fkdspstds_id', $fkdspstds_id, PDO::PARAM_INT);
            $stmt->bindParam(':fkdspscate_id', $fkdspscate_id, PDO::PARAM_INT);
            $stmt->bindParam(':fkisp_id_of', $fkisp_id_of, PDO::PARAM_INT);
            $stmt->bindParam(':filename', $filename);
            $stmt->bindParam(':title_en', $title_en);
            $stmt->bindParam(':title_kh', $title_kh);
            $stmt->bindParam(':description', $description);
            $stmt->bindParam(':status', $status);
            $stmt->bindParam(':filename1', $filename1);
            $stmt->bindParam(':filename2', $filename2);
            $stmt->bindParam(':filename3', $filename3);
            $stmt->bindParam(':mod_by', $mod_by, PDO::PARAM_INT);
            $stmt->bindParam(':id', $id, PDO::PARAM_INT);
            return $stmt->execute();
        } catch (PDOException $e) {
            error_log("Error updating data source (ID: $id): " . $e->getMessage());
            throw new Exception("Could not update data source. Please try again later.");
        }
    }

    /**
     * Deletes a data source from the database and its associated file.
     *
     * @param int $id The ID of the data source to delete.
     * @return bool True on success.
     * @throws Exception If a database error occurs.
     */
    public function deleteDataSource(int $id): bool {
        // Get the filename to delete the actual file
        $dataSource = $this->getDataSourceById($id);
        if ($dataSource) {
            $fileColumns = ['dspsds_filename', 'dspsds_filename1', 'dspsds_filename2', 'dspsds_filename3'];
            foreach ($fileColumns as $column) {
                if (!empty($dataSource[$column])) {
                    $filePath = $this->uploadDir . $dataSource[$column];
                    if (is_file($filePath)) {
                        unlink($filePath);
                    }
                }
            }
        }

        $sql = "DELETE FROM dsps_tbl_datasource WHERE pkdspsds_id = :id";
        try {
            $stmt = $this->pdo->prepare($sql);
            $stmt->bindParam(':id', $id);
            return $stmt->execute();
        } catch (PDOException $e) {
            error_log("Error deleting data source (ID: $id): " . $e->getMessage());
            throw new Exception("Could not delete data source. Please try again later.");
        }
    }

    /**
     * Retrieves a single data source by its ID.
     *
     * @param int $id The ID of the data source.
     * @return array|false The data source data as an associative array, or false if not found.
     * @throws Exception If a database error occurs.
     */
    public function getDataSourceById(int $id) {
        $sql = "SELECT ds.*, dspstds.dspstds_name_en AS data_type_name, dspstds.dspstds_name_kh AS data_type_name_kh,
                       dspscate.dspscate_title_en AS category_name,
                       isp.isp_firstname_en, isp.isp_lastname_en
                FROM dsps_tbl_datasource ds
                JOIN dsps_tbl_typedatasource dspstds ON ds.fkdspstds_id = dspstds.pkdspstds_id
                JOIN dsps_tbl_dspscategory dspscate ON ds.fkdspscate_id = dspscate.pkdspscate_id
                JOIN ist_tbl_people isp ON ds.fkisp_id_of = isp.pkisp_id
                WHERE ds.pkdspsds_id = :id";
        try {
            $stmt = $this->pdo->prepare($sql);
            $stmt->bindParam(':id', $id);
            $stmt->execute();
            return $stmt->fetch(PDO::FETCH_ASSOC);
        } catch (PDOException $e) {
            error_log("Error fetching data source by ID ($id): " . $e->getMessage());
            throw new Exception("Could not retrieve data source. Please try again later.");
        }
    }

    /**
     * Retrieves data sources based on various filters.
     *
     * @param int|null $owner_person_id Optional: Filter by data owner's person ID.
     * @param string|null $status Optional: Filter by status (e.g., 'Active', 'Pending Review').
     * @param int|null $category_id Optional: Filter by category ID.
     * @param string|null $search_query Optional: Search by title or description.
     * @param int|null $limit Optional: Limit the number of results.
     * @return array An array of data source data.
     * @throws Exception If a database error occurs.
     */
    public function getDataSources(
        ?int $owner_person_id = null,
        ?string $status = null,
        ?int $category_id = null,
        ?string $search_query = null,
        ?int $limit = null
    ): array {
        $sql = "SELECT ds.*, dspstds.dspstds_name_en AS data_type_name, dspstds.dspstds_name_kh AS data_type_name_kh,
                       dspscate.dspscate_title_en AS category_name,
                       isp.isp_firstname_en, isp.isp_lastname_en
                FROM dsps_tbl_datasource ds
                JOIN dsps_tbl_typedatasource dspstds ON ds.fkdspstds_id = dspstds.pkdspstds_id
                JOIN dsps_tbl_dspscategory dspscate ON ds.fkdspscate_id = dspscate.pkdspscate_id
                JOIN ist_tbl_people isp ON ds.fkisp_id_of = isp.pkisp_id";
        $conditions = [];
        $params = [];

        // Use strict checks for null to avoid issues with 0 and empty strings
        if ($owner_person_id !== null) {
            $conditions[] = "ds.fkisp_id_of = :owner_person_id";
            $params[':owner_person_id'] = $owner_person_id;
        }
        if ($status !== null) {
            $conditions[] = "ds.dspsds_status = :status";
            $params[':status'] = $status;
        }
        if ($category_id !== null) {
            $conditions[] = "ds.fkdspscate_id = :category_id";
            $params[':category_id'] = $category_id;
        }
        if ($search_query !== null && $search_query !== '') {
            $conditions[] = "(ds.dspsds_title_en LIKE :search_query OR ds.dspsds_description LIKE :search_query)";
            $params[':search_query'] = '%' . $search_query . '%';
        }

        if (!empty($conditions)) {
            $sql .= " WHERE " . implode(" AND ", $conditions);
        }

        $sql .= " ORDER BY ds.dspsds_reg_datetime DESC";

        // A safer way to handle LIMIT is to validate and concatenate the integer
        // directly, as not all PDO drivers support binding LIMIT values.
        if ($limit !== null && is_int($limit) && $limit > 0) {
            $sql .= " LIMIT " . $limit;
        }

        try {
            $stmt = $this->pdo->prepare($sql);
            // Pass the parameters array directly to execute()
            $stmt->execute($params);
            return $stmt->fetchAll(PDO::FETCH_ASSOC);
        } catch (PDOException $e) {
            error_log("Error fetching data sources: " . $e->getMessage());
            throw new Exception("Could not retrieve data sources. Please try again later.");
        }
    }

    /**
     * Gets the total count of data sources.
     *
     * @return int The total number of data sources.
     * @throws Exception If a database error occurs.
     */
    public function getTotalDataSources(): int {
        $sql = "SELECT COUNT(*) FROM dsps_tbl_datasource";
        try {
            $stmt = $this->pdo->query($sql);
            return $stmt->fetchColumn();
        } catch (PDOException $e) {
            error_log("Error getting total data sources count: " . $e->getMessage());
            throw new Exception("Could not retrieve data source count. Please try again later.");
        }
    }

    /**
     * Handles the upload of a data source file.
     *
     * @param array $file The $_FILES array for the uploaded file.
     * @return string The unique filename of the uploaded file.
     * @throws Exception If the upload fails.
     */
    public function handleDataSourceFileUpload(array $file, ?array $fileRules = null): string {
        if ($file['error'] !== UPLOAD_ERR_OK) {
            throw new Exception('File upload error: ' . $file['error']);
        }

        $rules = $fileRules ?? self::DEFAULT_FILE_RULES;
        $allowedMimeTypes = $rules['mimes'] ?? self::DEFAULT_FILE_RULES['mimes'];
        $allowedExtensions = $rules['extensions'] ?? self::DEFAULT_FILE_RULES['extensions'];
        $description = $rules['description'] ?? self::DEFAULT_FILE_RULES['description'];
        if (($file['size'] ?? 0) <= 0) {
            throw new Exception('Uploaded file is empty or missing.');
        }

        error_log(sprintf(
            '[DataSource] Upload requested: name=%s size=%s mime=%s rules=%s',
            $file['name'] ?? '',
            $file['size'] ?? 'unknown',
            $file['type'] ?? 'unknown',
            $description
        ));

        $finfo = new finfo(FILEINFO_MIME_TYPE);
        $mimeType = $finfo->file($file['tmp_name']);

        if (!in_array($mimeType, $allowedMimeTypes, true)) {
            throw new Exception('Invalid file type. Allowed formats: ' . $description);
        }

        $extension = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));

        if (!in_array($extension, array_map('strtolower', $allowedExtensions), true)) {
            throw new Exception('Invalid file extension. Allowed formats: ' . $description);
        }

        $originalStem = pathinfo($file['name'], PATHINFO_FILENAME);
        $slug = $this->slugifyFilename($originalStem);
        $uniqueFilename = 'datasource_' . uniqid();
        if (!empty($slug)) {
            $uniqueFilename .= '_' . $slug;
        }
        $uniqueFilename .= '.' . $extension;
        $destination = $this->uploadDir . $uniqueFilename;

        if (!move_uploaded_file($file['tmp_name'], $destination)) {
            $moved = false;
            if (PHP_SAPI === 'cli' || defined('STDIN')) {
                $moved = rename($file['tmp_name'], $destination);
            }
            if (!$moved) {
                error_log(sprintf('[DataSource] Failed to move uploaded file into %s (tmp=%s)', $destination, $file['tmp_name']));
                throw new Exception('Failed to move uploaded file.');
            }
        }

        error_log(sprintf('[DataSource] File stored as %s', $uniqueFilename));
        return $uniqueFilename;
    }
    private function resolvePrimaryFileRules(?string $typeName): array {
        if (!$typeName) {
            return self::DEFAULT_FILE_RULES;
        }
        $key = strtolower(trim($typeName));
        if (isset(self::TYPE_SPECIFIC_RULES[$key])) {
            return self::TYPE_SPECIFIC_RULES[$key];
        }
        foreach (self::TYPE_SPECIFIC_RULES as $alias => $rules) {
            if (str_contains($alias, $key) || str_contains($key, $alias)) {
                return $rules;
            }
        }
        // fallback to defaults but include description referencing requested type
        return self::DEFAULT_FILE_RULES;
    }

    public function getPrimaryFileRulesForType(?string $typeName): array {
        return $this->resolvePrimaryFileRules($typeName);
    }

    private function slugifyFilename(?string $value): string {
        if (!$value) {
            return '';
        }
        $value = strtolower($value);
        // Replace non-alphanumeric characters with hyphens.
        $value = preg_replace('/[^a-z0-9]+/i', '-', $value);
        $value = trim($value ?? '', '-');
        if ($value === '') {
            return '';
        }
        // Limit slug length to avoid overlong filenames.
        return substr($value, 0, 50);
    }

    // --- Data Source Permission Management (dsps_tbl_datasource_permission) ---

    /**
     * Requests permission for a data source for a specific user.
     *
     * @param int $fkdspsds_id The ID of the data source.
     * @param int $fkisp_id_of The person ID of the user requesting permission.
     * @param string $permission The type of permission ('Read', 'Download', 'Analyze').
     * @param string|null $notes Any notes from the requester.
     * @param int $reg_by The user ID who initiated the request (usually the data user themselves).
     * @return bool True on success.
     * @throws Exception If a database error occurs or a pending request already exists.
     */
    public function requestDataSourcePermission(
        int $fkdspsds_id,
        int $fkisp_id_of,
        string $permission,
        ?string $notes,
        int $reg_by,
        ?string $proofPath = null
    ): bool {
        // Check if a pending request already exists for this user and data source
        $checkSql = "SELECT COUNT(*) FROM dsps_tbl_datasource_permission
                     WHERE fkdspsds_id = :fkdspsds_id AND fkisp_id_of = :fkisp_id_of AND dspsdsp_status = 'Pending'";
        $stmtCheck = $this->pdo->prepare($checkSql);
        $stmtCheck->bindParam(':fkdspsds_id', $fkdspsds_id, PDO::PARAM_INT);
        $stmtCheck->bindParam(':fkisp_id_of', $fkisp_id_of, PDO::PARAM_INT);
        $stmtCheck->execute();
        if ($stmtCheck->fetchColumn() > 0) {
            throw new Exception("You already have a pending permission request for this data source.");
        }

        // Detect any existing permission (approved/rejected/etc.) to avoid duplicate key violations
        $existingSql = "SELECT dspsdsp_status FROM dsps_tbl_datasource_permission
                        WHERE fkdspsds_id = :fkdspsds_id AND fkisp_id_of = :fkisp_id_of LIMIT 1";
        $stmtExisting = $this->pdo->prepare($existingSql);
        $stmtExisting->bindParam(':fkdspsds_id', $fkdspsds_id, PDO::PARAM_INT);
        $stmtExisting->bindParam(':fkisp_id_of', $fkisp_id_of, PDO::PARAM_INT);
        $stmtExisting->execute();
        if ($row = $stmtExisting->fetch(PDO::FETCH_ASSOC)) {
            $statusLabel = $row['dspsdsp_status'] ?? 'processed';
            throw new Exception("This request was already {$statusLabel}. Please check My Permissions.");
        }

        $hasProofColumn = $this->ensurePermissionProofColumn();
        $columns = [
            'fkdspsds_id',
            'fkisp_id_of',
            'dspsdsp_permission',
            'dspsdsp_notes'
        ];
        $placeholders = [
            ':fkdspsds_id',
            ':fkisp_id_of',
            ':permission',
            ':notes'
        ];

        if ($hasProofColumn) {
            $columns[] = 'dspsdsp_proof_path';
            $placeholders[] = ':proof_path';
        }

        $columns[] = 'dspsdsp_status';
        $columns[] = 'dspsdsp_reg_by';
        $placeholders[] = ':status';
        $placeholders[] = ':reg_by';

        $sql = 'INSERT INTO dsps_tbl_datasource_permission (' . implode(', ', $columns) . ')
                VALUES (' . implode(', ', $placeholders) . ')';
        try {
            $stmt = $this->pdo->prepare($sql);
            $stmt->bindParam(':fkdspsds_id', $fkdspsds_id, PDO::PARAM_INT);
            $stmt->bindParam(':fkisp_id_of', $fkisp_id_of, PDO::PARAM_INT);
            $stmt->bindParam(':permission', $permission);
            if ($notes === null) {
                $stmt->bindValue(':notes', null, PDO::PARAM_NULL);
            } else {
                $stmt->bindValue(':notes', $notes, PDO::PARAM_STR);
            }

            if ($hasProofColumn) {
                if ($proofPath === null) {
                    $stmt->bindValue(':proof_path', null, PDO::PARAM_NULL);
                } else {
                    $stmt->bindValue(':proof_path', $proofPath, PDO::PARAM_STR);
                }
            }

            $stmt->bindValue(':status', 'Pending', PDO::PARAM_STR);
            $stmt->bindParam(':reg_by', $reg_by, PDO::PARAM_INT);
            return $stmt->execute();
        } catch (PDOException $e) {
            error_log("Error requesting data source permission: " . $e->getMessage());
            throw new Exception("Could not submit permission request. Please try again later.");
        }
    }

    /**
     * Retrieves permission requests for a specific Data Owner.
     *
     * @param int $owner_person_id The person ID of the Data Owner.
     * @param string|null $status Optional: Filter by status ('Pending', 'Approved', 'Rejected', 'Revoked').
     * @return array An array of permission request data.
     * @throws Exception If a database error occurs.
     */
    public function getPermissionRequestsForOwner(int $ownerPersonId, ?string $status = null): array {
        $hasProofColumn = $this->ensurePermissionProofColumn();
        $proofSelect = $hasProofColumn ? 't1.dspsdsp_proof_path' : 'NULL';
        $proofSelect .= ' AS dspsdsp_proof_path';

        $sql = "SELECT
                    t1.pkdspsdsp_id,
                    t2.dspsds_title_en,
                    t3.isp_firstname_en,
                    t3.isp_lastname_en,
                    t1.dspsdsp_permission,
                    t1.dspsdsp_reg_datetime,
                    t1.dspsdsp_notes,
                    $proofSelect,
                    t1.dspsdsp_status
                FROM dsps_tbl_datasource_permission AS t1
                JOIN dsps_tbl_datasource AS t2 ON t1.fkdspsds_id = t2.pkdspsds_id
                JOIN ist_tbl_people AS t3 ON t1.fkisp_id_of = t3.pkisp_id
                WHERE t2.fkisp_id_of = :ownerPersonId";
        
        if ($status !== null) {
            $sql .= " AND t1.dspsdsp_status = :status";
        }
        
        $sql .= " ORDER BY t1.dspsdsp_reg_datetime DESC";

        try {
            $stmt = $this->pdo->prepare($sql);
            $stmt->bindParam(':ownerPersonId', $ownerPersonId, PDO::PARAM_INT);
            if ($status !== null) {
                $stmt->bindParam(':status', $status);
            }
            $stmt->execute();
            return $stmt->fetchAll(PDO::FETCH_ASSOC);
        } catch (PDOException $e) {
            error_log("Database error in getPermissionRequestsForOwner: " . $e->getMessage());
            return [];
        }
    }

    /**
     * Retrieves a single permission request by its ID.
     * Includes data source owner's person ID for authorization checks.
     *
     * @param int $permissionId The ID of the permission request.
     * @return array|false The permission data as an associative array, or false if not found.
     * @throws Exception If a database error occurs.
     */
    public function getPermissionRequestById(int $permissionId) {
        $sql = "SELECT dsp.*, ds.fkisp_id_of AS datasource_owner_person_id
                FROM dsps_tbl_datasource_permission dsp
                JOIN dsps_tbl_datasource ds ON dsp.fkdspsds_id = ds.pkdspsds_id
                WHERE dsp.pkdspsdsp_id = :permission_id";
        try {
            $stmt = $this->pdo->prepare($sql);
            $stmt->bindParam(':permission_id', $permissionId, PDO::PARAM_INT);
            $stmt->execute();
            return $stmt->fetch(PDO::FETCH_ASSOC);
        } catch (PDOException $e) {
            error_log("Error fetching permission request by ID ($permissionId): " . $e->getMessage());
            throw new Exception("Could not retrieve permission request details. Please try again later.");
        }
    }

    /**
     * Retrieves all permission requests for DAC Staff, optionally filtered by status and search query.
     *
     * @param string|null $status_filter Optional: Filter by status ('Pending', 'Approved', 'Rejected', 'Revoked').
     * @param string|null $search_query Optional: Search by data source title, requester name, or owner name.
     * @return array An array of all permission request data.
     * @throws Exception If a database error occurs.
     */
    public function getAllPermissionRequests(?string $status_filter = null, ?string $search_query = null): array {
        $sql = "SELECT dsp.*, ds.dspsds_title_en, ds.dspsds_filename,
                       requester_p.isp_firstname_en AS requester_firstname, requester_p.isp_lastname_en AS requester_lastname,
                       owner_p.isp_firstname_en AS owner_firstname, owner_p.isp_lastname_en AS owner_lastname
                FROM dsps_tbl_datasource_permission dsp
                JOIN dsps_tbl_datasource ds ON dsp.fkdspsds_id = ds.pkdspsds_id
                JOIN ist_tbl_people requester_p ON dsp.fkisp_id_of = requester_p.pkisp_id
                JOIN ist_tbl_people owner_p ON ds.fkisp_id_of = owner_p.pkisp_id";
        $conditions = [];
        $params = [];

        if ($status_filter) {
            $conditions[] = "dsp.dspsdsp_status = :status_filter";
            $params[':status_filter'] = $status_filter;
        }

        if ($search_query) {
            $search_term = '%' . $search_query . '%';
            $conditions[] = "(ds.dspsds_title_en LIKE :search_query OR
                              requester_p.isp_firstname_en LIKE :search_query OR
                              requester_p.isp_lastname_en LIKE :search_query OR
                              owner_p.isp_firstname_en LIKE :search_query OR
                              owner_p.isp_lastname_en LIKE :search_query)";
            $params[':search_query'] = $search_term;
        }

        if (!empty($conditions)) {
            $sql .= " WHERE " . implode(" AND ", $conditions);
        }

        $sql .= " ORDER BY dsp.dspsdsp_reg_datetime DESC";

        try {
            $stmt = $this->pdo->prepare($sql);
            foreach ($params as $key => &$val) {
                $stmt->bindParam($key, $val, is_int($val) ? PDO::PARAM_INT : PDO::PARAM_STR);
            }
            $stmt->execute();
            return $stmt->fetchAll(PDO::FETCH_ASSOC);
        } catch (PDOException $e) {
            error_log("Error fetching all permission requests: " . $e->getMessage());
            throw new Exception("Could not retrieve all permission requests. Please try again later.");
        }
    }


    /**
     * Gets the count of pending permission requests.
     *
     * @return int The count of pending permission requests.
     * @throws Exception If a database error occurs.
     */
    public function getPendingPermissionRequestsCount(): int {
        $sql = "SELECT COUNT(*) FROM dsps_tbl_datasource_permission WHERE dspsdsp_status = 'Pending'";
        try {
            $stmt = $this->pdo->query($sql);
            return $stmt->fetchColumn();
        } catch (PDOException $e) {
            error_log("Error getting pending permission requests count: " . $e->getMessage());
            throw new Exception("Could not retrieve pending permission count. Please try again later.");
        }
    }

    /**
     * Aggregates total usage counts per data source owner (person id).
     *
     * @param int $limit Maximum number of owners to return.
     * @return array<int, array{owner_person_id:int, owner_name:string, usage_count:int}>
     */
    public function getUsageByOwner(int $limit = 8): array {
        $sql = "SELECT ds.fkisp_id_of AS owner_person_id,
                       CONCAT(p.isp_firstname_en, ' ', p.isp_lastname_en) AS owner_name,
                       COUNT(dsu.pkdspsdspused_id) AS usage_count
                FROM dsps_tbl_datasource_used dsu
                JOIN dsps_tbl_datasource ds ON dsu.fkdspsdsused_id = ds.pkdspsds_id
                JOIN ist_tbl_people p ON ds.fkisp_id_of = p.pkisp_id
                GROUP BY ds.fkisp_id_of, owner_name
                ORDER BY usage_count DESC
                LIMIT :limit";
        try {
            $stmt = $this->pdo->prepare($sql);
            $stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
            $stmt->execute();
            return $stmt->fetchAll(PDO::FETCH_ASSOC);
        } catch (PDOException $e) {
            error_log("Error fetching usage by owner: " . $e->getMessage());
            return [];
        }
    }

    /**
     * Updates the status of a data source permission request.
     *
     * @param int $permission_id The ID of the permission request.
     * @param string $new_status The new status ('Approved', 'Rejected', 'Revoked').
     * @param string|null $notes Optional notes for the status change.
     * @param int $mod_by The user ID of the Data Owner or DAC Staff who updated the status.
     * @return bool True on success.
     * @throws Exception If a database error occurs.
     */
    public function updatePermissionStatus(int $permissionId, string $newStatus, int $modifyingUserId, string $notes = ''): bool {
        $sql = "UPDATE dsps_tbl_datasource_permission
                SET
                    dspsdsp_status = :newStatus,
                    dspsdsp_mod_datetime = NOW(),
                    dspsdsp_reg_by = :modifyingUserId,
                    dspsdsp_notes = :notes
                WHERE pkdspsdsp_id = :permissionId";
        
        try {
            $stmt = $this->pdo->prepare($sql);
            $stmt->bindParam(':newStatus', $newStatus);
            $stmt->bindParam(':modifyingUserId', $modifyingUserId, PDO::PARAM_INT);
            $stmt->bindParam(':notes', $notes);
            $stmt->bindParam(':permissionId', $permissionId, PDO::PARAM_INT);
            return $stmt->execute();
        } catch (PDOException $e) {
            error_log("Error updating permission status: " . $e->getMessage());
            return false;
        }
    }

    /**
     * Checks if a user has a specific permission for a data source.
     *
     * @param int $user_person_id The person ID of the user.
     * @param int $data_source_id The ID of the data source.
     * @param string $permission_type The type of permission to check ('Read', 'Download', 'Analyze').
     * @return bool True if the user has the permission, false otherwise.
     * @throws Exception If a database error occurs.
     */
    public function hasPermission(int $user_person_id, int $data_source_id, string $permission_type): bool {
        $sql = "SELECT COUNT(*) FROM dsps_tbl_datasource_permission
                WHERE fkisp_id_of = :user_person_id
                AND fkdspsds_id = :data_source_id
                AND dspsdsp_permission = :permission_type
                AND dspsdsp_status = 'Approved'";
        try {
            $stmt = $this->pdo->prepare($sql);
            $stmt->bindParam(':user_person_id', $user_person_id, PDO::PARAM_INT);
            $stmt->bindParam(':data_source_id', $data_source_id, PDO::PARAM_INT);
            $stmt->bindParam(':permission_type', $permission_type);
            $stmt->execute();
            return $stmt->fetchColumn() > 0;
        } catch (PDOException $e) {
            error_log("Error checking permission for user ($user_person_id) on data source ($data_source_id): " . $e->getMessage());
            throw new Exception("Could not verify permission. Please try again later.");
        }
    }

    /**
     * Returns usage counts grouped by consuming user for data sources owned by a person.
     *
     * @param int $ownerPersonId
     * @param int $limit
     * @return array<int, array<string, mixed>>
     */
    public function getUsageByUserForOwner(int $ownerPersonId, int $limit = 10): array {
        $sql = "SELECT u.isu_name AS username,
                       COUNT(dsu.pkdspsdspused_id) AS usage_count
                FROM dsps_tbl_datasource_used dsu
                JOIN dsps_tbl_datasource ds ON dsu.fkdspsdsused_id = ds.pkdspsds_id
                JOIN ist_tbl_users u ON dsu.dspsdspused_reg_by = u.pkisu_id
                WHERE ds.fkisp_id_of = :ownerPersonId
                GROUP BY u.pkisu_id, u.isu_name
                ORDER BY usage_count DESC
                LIMIT :limit";
        try {
            $stmt = $this->pdo->prepare($sql);
            $stmt->bindValue(':ownerPersonId', $ownerPersonId, PDO::PARAM_INT);
            $stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
            $stmt->execute();
            return $stmt->fetchAll(PDO::FETCH_ASSOC);
        } catch (PDOException $e) {
            error_log("Error fetching usage by user for owner ($ownerPersonId): " . $e->getMessage());
            return [];
        }
    }

    /**
     * Retrieves usage counts for a specific user grouped by data source.
     *
     * @param int $userPersonId
     * @param int $limit
     * @return array<int, array<string, mixed>>
     */
    public function getUsageByDataSourceForUser(int $userPersonId, int $limit = 8): array {
        $sql = "SELECT ds.pkdspsds_id,
                       ds.dspsds_title_en,
                       COUNT(dsu.pkdspsdspused_id) AS usage_count
                FROM dsps_tbl_datasource_used dsu
                JOIN dsps_tbl_datasource ds ON dsu.fkdspsdsused_id = ds.pkdspsds_id
                WHERE dsu.dspsdspused_reg_by = :userPersonId
                GROUP BY ds.pkdspsds_id, ds.dspsds_title_en
                ORDER BY usage_count DESC
                LIMIT :limit";
        try {
            $stmt = $this->pdo->prepare($sql);
            $stmt->bindValue(':userPersonId', $userPersonId, PDO::PARAM_INT);
            $stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
            $stmt->execute();
            return $stmt->fetchAll(PDO::FETCH_ASSOC);
        } catch (PDOException $e) {
            error_log("Error fetching usage by data source for user ($userPersonId): " . $e->getMessage());
            return [];
        }
    }

    /**
     * Retrieves all data sources with owner, type, and category details.
     *
     * @param string|null $search Optional search across title, owner, or type.
     * @param string|null $status Optional status filter.
     * @return array
     */
    public function getAllDataSourcesDetailed(?string $search = null, ?string $status = null): array {
        $sql = "SELECT ds.*, 
                       dspstds.dspstds_name_en AS data_type_name,
                       dspscate.dspscate_title_en AS category_name,
                       CONCAT(p.isp_firstname_en, ' ', p.isp_lastname_en) AS owner_name
                FROM dsps_tbl_datasource ds
                JOIN dsps_tbl_typedatasource dspstds ON ds.fkdspstds_id = dspstds.pkdspstds_id
                JOIN dsps_tbl_dspscategory dspscate ON ds.fkdspscate_id = dspscate.pkdspscate_id
                JOIN ist_tbl_people p ON ds.fkisp_id_of = p.pkisp_id";

        $conditions = [];
        $params = [];

        if ($status !== null && $status !== '') {
            $conditions[] = "ds.dspsds_status = :status";
            $params[':status'] = $status;
        }

        if ($search !== null && $search !== '') {
            $conditions[] = "(ds.dspsds_title_en LIKE :search OR p.isp_firstname_en LIKE :search OR p.isp_lastname_en LIKE :search OR dspstds.dspstds_name_en LIKE :search)";
            $params[':search'] = '%' . $search . '%';
        }

        if (!empty($conditions)) {
            $sql .= " WHERE " . implode(' AND ', $conditions);
        }

        $sql .= " ORDER BY ds.dspsds_reg_datetime DESC";

        try {
            $stmt = $this->pdo->prepare($sql);
            foreach ($params as $key => $value) {
                $stmt->bindValue($key, $value);
            }
            $stmt->execute();
            return $stmt->fetchAll(PDO::FETCH_ASSOC);
        } catch (PDOException $e) {
            error_log("Error fetching all data sources: " . $e->getMessage());
            return [];
        }
    }

    /**
     * Updates the status of a data source.
     *
     * @param int $datasourceId
     * @param string $newStatus
     * @param int $modifyingUserId
     * @return bool
     */
    public function updateDataSourceStatus(int $datasourceId, string $newStatus, int $modifyingUserId): bool {
        $allowed = ['Active', 'Inactive', 'Pending Review', 'Published'];
        if (!in_array($newStatus, $allowed, true)) {
            throw new InvalidArgumentException("Invalid data source status value.");
        }

        $sql = "UPDATE dsps_tbl_datasource
                SET dspsds_status = :status,
                    dspsds_mod_datetime = CURRENT_TIMESTAMP,
                    dspsds_reg_by = :modBy
                WHERE pkdspsds_id = :id";

        try {
            $stmt = $this->pdo->prepare($sql);
            $stmt->bindParam(':status', $newStatus);
            $stmt->bindValue(':modBy', $modifyingUserId, PDO::PARAM_INT);
            $stmt->bindValue(':id', $datasourceId, PDO::PARAM_INT);
            return $stmt->execute();
        } catch (PDOException $e) {
            error_log("Error updating data source status (ID: $datasourceId): " . $e->getMessage());
            return false;
        }
    }

    /**
     * Retrieves all permissions for a specific user.
     *
     * @param int $user_person_id The person ID of the user.
     * @param string|null $status Optional: Filter by status ('Pending', 'Approved', 'Rejected', 'Revoked').
     * @return array An array of permission data.
     * @throws Exception If a database error occurs.
     */
    public function getUserPermissions(int $user_person_id, ?string $status = null): array {
        $sql = "SELECT dsp.*, ds.dspsds_title_en, ds.dspsds_description, ds.dspsds_filename,
                       ds.dspsds_filename1, ds.dspsds_filename2, ds.dspsds_filename3,
                       dspstds.dspstds_name_en AS data_type_name, dspscate.dspscate_title_en AS category_name
                FROM dsps_tbl_datasource_permission dsp
                JOIN dsps_tbl_datasource ds ON dsp.fkdspsds_id = ds.pkdspsds_id
                JOIN dsps_tbl_typedatasource dspstds ON ds.fkdspstds_id = dspstds.pkdspstds_id
                JOIN dsps_tbl_dspscategory dspscate ON ds.fkdspscate_id = dspscate.pkdspscate_id
                WHERE dsp.fkisp_id_of = :user_person_id";
        $params = [':user_person_id' => $user_person_id];

        if ($status) {
            $sql .= " AND dsp.dspsdsp_status = :status";
            $params[':status'] = $status;
        }

        $sql .= " ORDER BY dsp.dspsdsp_reg_datetime DESC";

        try {
            $stmt = $this->pdo->prepare($sql);
            foreach ($params as $key => &$val) {
                $stmt->bindParam($key, $val, is_int($val) ? PDO::PARAM_INT : PDO::PARAM_STR);
            }
            $stmt->execute();
            return $stmt->fetchAll(PDO::FETCH_ASSOC);
        } catch (PDOException $e) {
            error_log("Error fetching user permissions ($user_person_id): " . $e->getMessage());
            throw new Exception("Could not retrieve your permissions. Please try again later.");
        }
    }

    /**
     * Retrieves all approved data sources a user is permitted to analyse/download.
     *
     * @param int $userPersonId The person ID tied to the current session.
     * @return array
     * @throws Exception
     */
    public function getApprovedDataSourcesForUser(int $userPersonId): array {
        return $this->getUserPermissions($userPersonId, 'Approved');
    }

    /**
     * Synchronises the approved data sources for a user into a dedicated Jupyter workspace directory.
     * Files are symlinked when possible to avoid duplication; if symlinks are not permitted, files are copied.
     *
     * @param int $userPersonId The person ID of the user.
     * @param string $workspaceRoot Absolute path to the shared Jupyter workspace root.
     * @return array{synced: array<int, array<string, mixed>>, missing: array<int, array<string, mixed>>, workspace_dir: string}
     * @throws Exception When the workspace cannot be prepared.
     */
    public function prepareJupyterWorkspace(int $userPersonId, string $workspaceRoot): array {
        if (!is_dir($workspaceRoot) && !mkdir($workspaceRoot, 0775, true) && !is_dir($workspaceRoot)) {
            throw new RuntimeException("Unable to create Jupyter workspace root at {$workspaceRoot}");
        }

        $userFolderName = 'user_' . $userPersonId;
        $userWorkspaceDir = rtrim($workspaceRoot, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $userFolderName;

        if (!is_dir($userWorkspaceDir) && !mkdir($userWorkspaceDir, 0775, true) && !is_dir($userWorkspaceDir)) {
            throw new RuntimeException("Unable to create user workspace directory at {$userWorkspaceDir}");
        }

        $this->purgeSyncedArtifacts($userWorkspaceDir);

        $approvedSources = $this->getApprovedDataSourcesForUser($userPersonId);
        $synced = [];
        $missing = [];

        foreach ($approvedSources as $index => $permission) {
            $sourceId = (int)($permission['fkdspsds_id'] ?? 0);
            $title = $permission['dspsds_title_en'] ?? ('Data Source ' . $sourceId);
            $fileColumns = ['dspsds_filename', 'dspsds_filename1', 'dspsds_filename2', 'dspsds_filename3'];
            $fileQueue = [];

            foreach ($fileColumns as $column) {
                $candidate = trim((string)($permission[$column] ?? ''));
                if ($candidate !== '' && !in_array($candidate, $fileQueue, true)) {
                    $fileQueue[] = $candidate;
                }
            }

            if (empty($fileQueue)) {
                $missing[] = [
                    'datasource_id' => $sourceId,
                    'title' => $title,
                    'reason' => 'No files associated with this data source.',
                ];
                continue;
            }

            foreach ($fileQueue as $fileIndex => $filename) {
                $sourcePath = $this->uploadDir . $filename;
                if (!is_file($sourcePath)) {
                    $missing[] = [
                        'datasource_id' => $sourceId,
                        'title' => $title,
                        'reason' => 'File not found on disk: ' . $filename,
                    ];
                    continue;
                }

                $extension = strtolower((string) pathinfo($sourcePath, PATHINFO_EXTENSION));

                if ($extension === 'ipynb') {
                    $missing[] = [
                        'datasource_id' => $sourceId,
                        'title' => $title,
                        'reason' => 'Notebook files (.ipynb) are personal workspaces and are not shared via sync.',
                    ];
                    continue;
                }

                $safeTitle = $this->sanitizeFileName($title);
                $targetBase = sprintf('%03d_%s', $index + 1, $safeTitle ?: ('datasource_' . $sourceId));
                if ($fileIndex > 0) {
                    $targetBase .= '_file' . ($fileIndex + 1);
                }
                $targetName = $extension ? "{$targetBase}.{$extension}" : $targetBase;
                $targetPath = $userWorkspaceDir . DIRECTORY_SEPARATOR . $targetName;

                if (file_exists($targetPath) || is_link($targetPath)) {
                    unlink($targetPath);
                }

                $linked = @symlink($sourcePath, $targetPath);
                if (!$linked) {
                    $linked = @copy($sourcePath, $targetPath);
                }

                if ($linked) {
                    $synced[] = [
                        'datasource_id' => $sourceId,
                        'title' => $title,
                        'source_filename' => $filename,
                        'relative_path' => $userFolderName . '/' . $targetName,
                        'absolute_path' => $targetPath,
                        'data_type' => $permission['data_type_name'] ?? null,
                        'category' => $permission['category_name'] ?? null,
                    ];
                } else {
                    $missing[] = [
                        'datasource_id' => $sourceId,
                        'title' => $title,
                        'reason' => 'Unable to sync file into workspace: ' . $filename,
                    ];
                }
            }
        }

        $readmePath = $userWorkspaceDir . DIRECTORY_SEPARATOR . 'README.txt';
        $readmeBody = "Approved data sources for R in JupyterHub are synced to this folder.\n"
            . "Each file is a symlink (or copy) of the original upload.\n"
            . "Only data sources with approved permissions for your account appear here.\n"
            . "Synced on: " . date('c') . "\n";
        file_put_contents($readmePath, $readmeBody);

        $manifestPath = $userWorkspaceDir . DIRECTORY_SEPARATOR . 'manifest.json';
        file_put_contents($manifestPath, json_encode([
            'generated_at' => date('c'),
            'synced' => $synced,
            'missing' => $missing,
        ], JSON_PRETTY_PRINT));

        return [
            'synced' => $synced,
            'missing' => $missing,
            'workspace_dir' => $userWorkspaceDir,
        ];
    }

    /**
     * Removes previously synced artefacts without touching user-authored notebooks.
     *
     * @param string $userWorkspaceDir
     * @return void
     */
    private function purgeSyncedArtifacts(string $userWorkspaceDir): void {
        $manifestPath = $userWorkspaceDir . DIRECTORY_SEPARATOR . 'manifest.json';

        if (is_readable($manifestPath)) {
            $manifest = json_decode((string) file_get_contents($manifestPath), true);
            if (isset($manifest['synced']) && is_array($manifest['synced'])) {
                foreach ($manifest['synced'] as $syncedItem) {
                    if (empty($syncedItem['relative_path'])) {
                        continue;
                    }

                    $basename = basename($syncedItem['relative_path']);
                    if ($basename === '' || $basename === '.' || $basename === '..') {
                        continue;
                    }

                    $targetPath = $userWorkspaceDir . DIRECTORY_SEPARATOR . $basename;
                    if (is_file($targetPath) || is_link($targetPath)) {
                        @unlink($targetPath);
                    }
                }
            }
        }

        foreach (['README.txt', 'manifest.json'] as $generatedFile) {
            $filePath = $userWorkspaceDir . DIRECTORY_SEPARATOR . $generatedFile;
            if (is_file($filePath)) {
                @unlink($filePath);
            }
        }

        // Legacy clean-up: remove any auto-synced notebooks that may still exist from older runs.
        // The sync process previously generated notebooks using a ###_ prefix; strip those out so
        // personal notebooks authored by the user (custom names) remain untouched.
        $iterator = new FilesystemIterator($userWorkspaceDir, FilesystemIterator::SKIP_DOTS);
        foreach ($iterator as $item) {
            if ($item->isFile() && preg_match('/^\d{3}_.+\.ipynb$/i', $item->getFilename())) {
                @unlink($item->getPathname());
            }
        }
    }

    /**
     * Removes all files and folders within a directory without deleting the directory itself.
     *
     * @param string $directory
     * @return void
     */
    private function clearDirectory(string $directory): void {
        if (!is_dir($directory)) {
            return;
        }

        $iterator = new FilesystemIterator($directory, FilesystemIterator::SKIP_DOTS);
        foreach ($iterator as $item) {
            if ($item->isDir()) {
                $this->deleteDirectory($item->getPathname());
            } else {
                unlink($item->getPathname());
            }
        }
    }

    /**
     * Recursively deletes a directory.
     *
     * @param string $path
     * @return void
     */
    private function deleteDirectory(string $path): void {
        if (!is_dir($path)) {
            return;
        }

        $items = new FilesystemIterator($path, FilesystemIterator::SKIP_DOTS);
        foreach ($items as $item) {
            if ($item->isDir()) {
                $this->deleteDirectory($item->getPathname());
            } else {
                unlink($item->getPathname());
            }
        }
        rmdir($path);
    }

    /**
     * Sanitises a string for safe use as a file name.
     *
     * @param string $name
     * @return string
     */
    private function sanitizeFileName(string $name): string {
        $sanitised = preg_replace('/[^a-zA-Z0-9-_]+/', '_', $name);
        return trim($sanitised, '_');
    }

    // --- Usage Logging (dsps_tbl_anonymous, dsps_tbl_datasource_used) ---

    /**
     * Logs an anonymous view of a data source introduction.
     *
     * @param int $fkdspsds_id The ID of the data source viewed.
     * @param string|null $client_ip The IP address of the viewer.
     * @param string $action The action performed (e.g., 'View Introduction').
     * @return bool True on success.
     * @throws Exception If a database error occurs.
     */
    public function logAnonymousView(int $fkdspsds_id, ?string $client_ip, string $action): bool {
        $sql = "INSERT INTO dsps_tbl_anonymous (fkdspsds_id, dspsano_client_ip, dspsano_action)
                VALUES (:fkdspsds_id, :client_ip, :action)";
        try {
            $stmt = $this->pdo->prepare($sql);
            $stmt->bindParam(':fkdspsds_id', $fkdspsds_id, PDO::PARAM_INT);
            $stmt->bindParam(':client_ip', $client_ip);
            $stmt->bindParam(':action', $action);
            return $stmt->execute();
        } catch (PDOException $e) {
            error_log("Error logging anonymous view for data source ($fkdspsds_id): " . $e->getMessage());
            throw new Exception("Could not log anonymous view.");
        }
    }

    /**
     * Logs a registered user's usage of a data source.
     *
     * @param int $fkdspsdsused_id The ID of the data source used.
     * @param int $fkisp_id_of The person ID of the user who used it.
     * @param string $action The action performed (e.g., 'Downloaded', 'Accessed API', 'Ran Analysis').
     * @param int $reg_by The user ID who performed the action.
     * @return bool True on success.
     * @throws Exception If a database error occurs.
     */
    public function logDataSourceUsage(int $fkdspsdsused_id, int $fkisp_id_of, string $action, int $reg_by): bool {
        $sql = "INSERT INTO dsps_tbl_datasource_used (fkdspsdsused_id, fkisp_id_of, dspsdspused_action, dspsdspused_reg_by)
                VALUES (:fkdspsdsused_id, :fkisp_id_of, :action, :reg_by)";
        try {
            $stmt = $this->pdo->prepare($sql);
            $stmt->bindParam(':fkdspsdsused_id', $fkdspsdsused_id, PDO::PARAM_INT);
            $stmt->bindParam(':fkisp_id_of', $fkisp_id_of, PDO::PARAM_INT);
            $stmt->bindParam(':action', $action);
            $stmt->bindParam(':reg_by', $reg_by, PDO::PARAM_INT);
            return $stmt->execute();
        } catch (PDOException $e) {
            error_log("Error logging data source usage for user ($fkisp_id_of) on data source ($fkdspsdsused_id): " . $e->getMessage());
            throw new Exception("Could not log data source usage.");
        }
    }

    /**
     * Retrieves usage logs for a specific data source.
     *
     * @param int $data_source_id The ID of the data source.
     * @param string|null $action Optional: Filter by action.
     * @return array An array of usage log data.
     * @throws Exception If a database error occurs.
     */
    public function getDataSourceUsageLogs(int $data_source_id, ?string $action = null): array {
        $sql = "SELECT dsu.*, p.isp_firstname_en, p.isp_lastname_en
                FROM dsps_tbl_datasource_used dsu
                JOIN ist_tbl_people p ON dsu.fkisp_id_of = p.pkisp_id
                WHERE dsu.fkdspsdsused_id = :data_source_id";
        $params = [':data_source_id' => $data_source_id];

        if ($action) {
            $sql .= " AND dsu.dspsdspused_action = :action";
            $params[':action'] = $action;
        }

        $sql .= " ORDER BY dsu.dspsdspused_reg_datetime DESC";

        try {
            $stmt = $this->pdo->prepare($sql);
            foreach ($params as $key => &$val) {
                $stmt->bindParam($key, $val, is_int($val) ? PDO::PARAM_INT : PDO::PARAM_STR);
            }
            $stmt->execute();
            return $stmt->fetchAll(PDO::FETCH_ASSOC);
        } catch (PDOException $e) {
            error_log("Error fetching data source usage logs for ($data_source_id): " . $e->getMessage());
            throw new Exception("Could not retrieve usage logs. Please try again later.");
        }
    }

    /**
     * Retrieves a user's download history.
     *
     * @param int $user_person_id The person ID of the user.
     * @return array An array of downloaded data sources.
     * @throws Exception If a database error occurs.
     */
    public function getUserDownloads(int $user_person_id): array {
        $sql = "SELECT dsu.*, ds.dspsds_title_en, ds.dspsds_filename,
                       dspstds.dspstds_name_en AS data_type_name, dspscate.dspscate_title_en AS category_name
                FROM dsps_tbl_datasource_used dsu
                JOIN dsps_tbl_datasource ds ON dsu.fkdspsdsused_id = ds.pkdspsds_id
                JOIN dsps_tbl_typedatasource dspstds ON ds.fkdspstds_id = dspstds.pkdspstds_id
                JOIN dsps_tbl_dspscategory dspscate ON ds.fkdspscate_id = dspscate.pkdspscate_id
                WHERE dsu.fkisp_id_of = :user_person_id AND dsu.dspsdspused_action = 'Downloaded'
                ORDER BY dsu.dspsdspused_reg_datetime DESC";
        try {
            $stmt = $this->pdo->prepare($sql);
            $stmt->bindParam(':user_person_id', $user_person_id, PDO::PARAM_INT);
            $stmt->execute();
            return $stmt->fetchAll(PDO::FETCH_ASSOC);
        } catch (PDOException $e) {
            error_log("Error fetching user downloads for ($user_person_id): " . $e->getMessage());
            throw new Exception("Could not retrieve your downloads. Please try again later.");
        }
    }

    // --- Classification Methods (Moved from Classifications.php for simplicity, or keep separate) ---
    // Assuming these methods are here based on previous discussions.
    // If you have a separate Classifications.php, ensure these are in that file.

    /**
     * Retrieves all data types.
     * @return array
     * @throws Exception If a database error occurs.
     */
    public function getAllDataTypes(): array {
        $sql = "SELECT * FROM dsps_tbl_typedatasource ORDER BY dspstds_name_en ASC";
        try {
            $stmt = $this->pdo->query($sql);
            return $stmt->fetchAll(PDO::FETCH_ASSOC);
        } catch (PDOException $e) {
            error_log("Error fetching all data types: " . $e->getMessage());
            throw new Exception("Could not retrieve data types. Please try again later.");
        }
    }
    public function getDataTypeById(int $typeId): ?array {
        $sql = "SELECT * FROM dsps_tbl_typedatasource WHERE pkdspstds_id = :type_id";
        $stmt = $this->pdo->prepare($sql);
        $stmt->bindParam(':type_id', $typeId, PDO::PARAM_INT);
        $stmt->execute();
        $result = $stmt->fetch(PDO::FETCH_ASSOC);
        return $result ?: null;
    }

    /**
     * Retrieves all data categories.
     * @return array
     * @throws Exception If a database error occurs.
     */
    public function getAllCategories(): array {
        $sql = "SELECT * FROM dsps_tbl_dspscategory ORDER BY dspscate_title_en ASC";
        try {
            $stmt = $this->pdo->query($sql);
            return $stmt->fetchAll(PDO::FETCH_ASSOC);
        } catch (PDOException $e) {
            error_log("Error fetching all categories: " . $e->getMessage());
            throw new Exception("Could not retrieve categories. Please try again later.");
        }
    }

    /**
     * Get a list of data sources, optionally filtered by category and search query.
     * @param int|null $categoryId The ID of the category to filter by.
     * @param string|null $searchQuery The search query to filter by title or description.
     * @return array An array of data source records.
     */
    public function getFilteredDataSources($categoryId = null, $searchQuery = null)
    {
        try {
            // Use LEFT JOIN to get data from related tables.
            // All table and column names are now verified against the provided SQL dump.
            $sql = "
                SELECT 
                    ds.*,
                    COALESCE(cat.dspscate_title_en, 'Not specified') AS category_name,
                    COALESCE(dt.dspstds_name_en, 'Not specified') AS data_type_name,
                    COALESCE(p.isp_firstname_en, 'Not specified') AS isp_firstname_en,
                    COALESCE(p.isp_lastname_en, 'Not specified') AS isp_lastname_en
                FROM dsps_tbl_datasource AS ds
                LEFT JOIN dsps_tbl_dspscategory AS cat ON ds.fkdspscate_id = cat.pkdspscate_id
                LEFT JOIN dsps_tbl_typedatasource AS dt ON ds.fkdspstds_id = dt.pkdspstds_id
                LEFT JOIN ist_tbl_people AS p ON ds.fkisp_id_of = p.pkisp_id
                WHERE ds.dspsds_status = 'Active'
            ";

            $params = [];
            $whereClauses = [];

            if ($categoryId) {
                $whereClauses[] = "ds.fkdspscate_id = :category_id";
                $params[':category_id'] = $categoryId;
            }

            if ($searchQuery) {
                $search = "%" . $searchQuery . "%";
                $whereClauses[] = "(ds.dspsds_title_en LIKE :search OR ds.dspsds_description LIKE :search)";
                $params[':search'] = $search;
            }

            if (!empty($whereClauses)) {
                $sql .= " AND " . implode(" AND ", $whereClauses);
            }
            
            // Order by title for a consistent display
            $sql .= " ORDER BY ds.dspsds_title_en ASC";

            $stmt = $this->pdo->prepare($sql);
            $stmt->execute($params);
            
            return $stmt->fetchAll(PDO::FETCH_ASSOC);

        } catch (PDOException $e) {
            // Log the error and return an empty array to prevent further issues
            error_log("Database error in getFilteredDataSources: " . $e->getMessage());
            return [];
        }
    }

    private function tableColumnExists(string $table, string $column): bool {
        $cacheKey = $table . '.' . $column;
        if (array_key_exists($cacheKey, $this->columnExistenceCache)) {
            return $this->columnExistenceCache[$cacheKey];
        }

        if (!preg_match('/^[a-zA-Z0-9_]+$/', $table)) {
            return false;
        }

        $sql = sprintf('SHOW COLUMNS FROM `%s` LIKE :column', $table);
        try {
            $stmt = $this->pdo->prepare($sql);
            $stmt->bindParam(':column', $column, PDO::PARAM_STR);
            $stmt->execute();
            $exists = (bool) $stmt->fetch(PDO::FETCH_ASSOC);
        } catch (PDOException $e) {
            error_log('Error checking column existence: ' . $e->getMessage());
            // Assume column exists if we cannot verify to avoid silently skipping new features.
            $exists = true;
        }

        $this->columnExistenceCache[$cacheKey] = $exists;
        return $exists;
    }

    private function ensurePermissionProofColumn(): bool {
        $table = 'dsps_tbl_datasource_permission';
        $column = 'dspsdsp_proof_path';
        $cacheKey = $table . '.' . $column;

        if ($this->tableColumnExists($table, $column)) {
            return true;
        }

        $alterSql = "ALTER TABLE `{$table}` ADD COLUMN `{$column}` VARCHAR(255) DEFAULT NULL AFTER dspsdsp_notes";
        try {
            $this->pdo->exec($alterSql);
            $this->columnExistenceCache[$cacheKey] = true;
            return true;
        } catch (PDOException $e) {
            error_log('Failed to add proof column: ' . $e->getMessage());
            return false;
        }
    }
}
