<?php

/**
 * Unified Database connection class for Post-ERG thesis database
 * Combines functionality from both front-backend and formulaire
 * Supports both singleton (front-backend) and direct instantiation (formulaire)
 */
class Database {
    private static $instance = null;
    private $pdo;
    private $dbPath;

    /**
     * Constructor - public to support both singleton and direct instantiation
     * @param string $dbPath Optional database path override
     */
    public function __construct($dbPath = null) {
        $this->dbPath = $this->determineDatabasePath($dbPath);

        try {
            $this->pdo = new PDO('sqlite:' . $this->dbPath);
            $this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
            $this->pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);

            // Enable foreign key constraints + performance pragmas
            $this->pdo->exec('PRAGMA foreign_keys = ON');
            $this->pdo->exec('PRAGMA journal_mode = WAL');
            $this->pdo->exec('PRAGMA synchronous = NORMAL');
            $this->pdo->exec('PRAGMA cache_size = -8000');
        } catch (PDOException $e) {
            error_log("Database connection failed: " . $e->getMessage());
            throw new Exception("Impossible de se connecter à la base de données.");
        }
    }

    /**
     * Determine database path.
     * Priority: explicit override → DB_ENV env-var → sapi auto-detect.
     * APP_ROOT is defined by bootstrap.php before any controller loads Database.
     */
    private function determineDatabasePath($customPath = null): string {
        if ($customPath !== null && file_exists($customPath)) {
            return $customPath;
        }

        $root    = defined('APP_ROOT') ? APP_ROOT : __DIR__ . '/..';
        $testDb  = $root . '/storage/test.db';
        $prodDb  = $root . '/storage/posterg.db';

        $env = getenv('DB_ENV');
        if ($env === 'test') return $testDb;
        if ($env === 'prod') return $prodDb;

        // php -S (dev server) → test DB; everything else (nginx/fpm) → prod DB
        return php_sapi_name() === 'cli-server' ? $testDb : $prodDb;
    }

    /**
     * Get singleton instance (for front-backend)
     * @return Database
     */
    public static function getInstance() {
        if (self::$instance === null) {
            self::$instance = new self();
        }
        return self::$instance;
    }

    /**
     * Get PDO connection
     * @return PDO
     */
    public function getConnection() {
        return $this->pdo;
    }

    /**
     * Get PDO instance (alias for formulaire compatibility)
     * @return PDO
     */
    public function getPDO() {
        return $this->pdo;
    }

    /**
     * Return the resolved path of the database file in use.
     */
    public function getDatabasePath(): string {
        return $this->dbPath;
    }

    // ========================================================================
    // TRANSACTION SUPPORT (from formulaire)
    // ========================================================================

    /**
     * Begin a transaction
     */
    public function beginTransaction() {
        return $this->pdo->beginTransaction();
    }

    /**
     * Commit a transaction
     */
    public function commit() {
        return $this->pdo->commit();
    }

    /**
     * Rollback a transaction
     */
    public function rollback() {
        return $this->pdo->rollback();
    }

    // ========================================================================
    // PUBLIC SITE METHODS (from front-backend)
    // ========================================================================

    /**
     * Get all published theses with pagination
     */
    public function getPublishedTheses($limit = 10, $offset = 0) {
        $sql = "SELECT * FROM v_theses_public ORDER BY year DESC, title ASC LIMIT :limit OFFSET :offset";
        $stmt = $this->pdo->prepare($sql);
        $stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
        $stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
        $stmt->execute();
        return $stmt->fetchAll();
    }

    /**
     * Get theses from the latest published year, in random order (per request).
     * Used for the default home page view.
     */
    public function getLatestYearTheses(int $limit = 24): array {
        $sql = "SELECT * FROM v_theses_public
                WHERE year = (SELECT MAX(year) FROM theses WHERE is_published = 1)
                ORDER BY RANDOM()
                LIMIT :limit";
        $stmt = $this->pdo->prepare($sql);
        $stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
        $stmt->execute();
        return $stmt->fetchAll();
    }

    /**
     * Get the latest year that has published theses
     */
    public function getLatestPublishedYear(): ?int {
        $stmt = $this->pdo->query("SELECT MAX(year) FROM theses WHERE is_published = 1");
        $val = $stmt->fetchColumn();
        return $val ? (int)$val : null;
    }

    /**
     * Count all published theses
     */
    public function countPublishedTheses() {
        $sql = "SELECT COUNT(*) as count FROM theses WHERE is_published = 1";
        $stmt = $this->pdo->query($sql);
        $result = $stmt->fetch();
        return $result['count'];
    }

    /**
     * Get thesis by ID with all related data (for public site)
     */
    public function getThesisById($id) {
        $sql = "SELECT * FROM v_theses_full WHERE id = :id AND is_published = 1";
        $stmt = $this->pdo->prepare($sql);
        $stmt->bindValue(':id', $id, PDO::PARAM_INT);
        $stmt->execute();
        $thesis = $stmt->fetch();

        if (!$thesis) {
            return null;
        }

        // Get associated files
        $thesis['files'] = $this->getThesisFiles($id);

        return $thesis;
    }

    /**
     * Get thesis by ID (for admin - includes unpublished)
     * @param int $id Thesis ID
     * @return array|null Thesis data or null if not found
     */
    public function getThesis($id) {
        $stmt = $this->pdo->prepare("SELECT * FROM v_theses_full WHERE id = ?");
        $stmt->execute([$id]);
        return $stmt->fetch();
    }

    /**
     * Get files associated with a thesis
     */
    public function getThesisFiles($thesisId) {
        $sql = "SELECT * FROM thesis_files WHERE thesis_id = :thesis_id ORDER BY file_type, uploaded_at";
        $stmt = $this->pdo->prepare($sql);
        $stmt->bindValue(':thesis_id', $thesisId, PDO::PARAM_INT);
        $stmt->execute();
        return $stmt->fetchAll();
    }

    // ========================================================================
    // SEARCH FUNCTIONALITY (from front-backend - secure implementation)
    // ========================================================================

    /**
     * Escape LIKE wildcards in user input to prevent wildcard injection
     */
    private function escapeLikeString($string) {
        return str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $string);
    }

    /**
     * Validate and sanitize search parameters
     * @throws InvalidArgumentException if validation fails
     */
    private function validateSearchParams($params) {
        $validated = [];

        if (!empty($params['query'])) {
            $query = trim($params['query']);
            if (strlen($query) > 200) {
                throw new InvalidArgumentException("Search query too long (max 200 characters)");
            }
            $validated['query'] = $this->escapeLikeString($query);
        }

        if (!empty($params['year'])) {
            $year = intval($params['year']);
            if ($year < 1900 || $year > 2100) {
                throw new InvalidArgumentException("Invalid year");
            }
            $validated['year'] = $year;
        }

        if (!empty($params['orientation'])) {
            $orientation = trim($params['orientation']);
            if (strlen($orientation) > 100) {
                throw new InvalidArgumentException("Orientation name too long");
            }
            $validated['orientation'] = $this->escapeLikeString($orientation);
        }

        if (!empty($params['ap_program'])) {
            $ap = trim($params['ap_program']);
            if (strlen($ap) > 100) {
                throw new InvalidArgumentException("AP program name too long");
            }
            $validated['ap_program'] = $this->escapeLikeString($ap);
        }

        if (!empty($params['finality'])) {
            $finality = trim($params['finality']);
            if (strlen($finality) > 100) {
                throw new InvalidArgumentException("Finality name too long");
            }
            $validated['finality'] = $this->escapeLikeString($finality);
        }

        if (!empty($params['keyword'])) {
            $keyword = trim($params['keyword']);
            if (strlen($keyword) > 100) {
                throw new InvalidArgumentException("Keyword too long");
            }
            $validated['keyword'] = $this->escapeLikeString($keyword);
        }

        if (!empty($params['format'])) {
            $format = trim($params['format']);
            if (strlen($format) > 100) {
                throw new InvalidArgumentException("Format name too long");
            }
            $validated['format'] = $this->escapeLikeString($format);
        }

        if (!empty($params['language'])) {
            $language = trim($params['language']);
            if (strlen($language) > 50) {
                throw new InvalidArgumentException("Language name too long");
            }
            $validated['language'] = $this->escapeLikeString($language);
        }

        if (isset($params['is_doctoral'])) {
            $validated['is_doctoral'] = (bool)$params['is_doctoral'];
        }

        return $validated;
    }

    /**
     * Build WHERE conditions and named bindings from validated search params.
     * Always includes the `is_published = 1` guard.
     *
     * @param  array $params  Already-validated params (output of validateSearchParams)
     * @return array{0: string[], 1: array<string,mixed>}  [$conditions, $bindings]
     */
    private function buildSearchConditions(array $params): array {
        $conditions = ["vp.is_published = 1"];
        $bindings = [];

        if (!empty($params['query'])) {
            $conditions[] = "(
                vp.title LIKE :query ESCAPE '\\' OR
                vp.subtitle LIKE :query ESCAPE '\\' OR
                vp.synopsis LIKE :query ESCAPE '\\' OR
                vp.authors LIKE :query ESCAPE '\\' OR
                vp.supervisors LIKE :query ESCAPE '\\' OR
                EXISTS (
                    SELECT 1 FROM thesis_tags tt2
                    JOIN tags tg2 ON tg2.id = tt2.tag_id
                    WHERE tt2.thesis_id = vp.id AND tg2.name LIKE :query ESCAPE '\\'
                )
            )";
            $bindings[':query'] = '%' . $params['query'] . '%';
        }

        if (!empty($params['year'])) {
            $conditions[] = "vp.year = :year";
            $bindings[':year'] = $params['year'];
        }

        if (!empty($params['orientation'])) {
            $conditions[] = "vp.orientation LIKE :orientation ESCAPE '\\'";
            $bindings[':orientation'] = '%' . $params['orientation'] . '%';
        }

        if (!empty($params['ap_program'])) {
            $conditions[] = "vp.ap_program LIKE :ap_program ESCAPE '\\'";
            $bindings[':ap_program'] = '%' . $params['ap_program'] . '%';
        }

        if (!empty($params['finality'])) {
            $conditions[] = "vp.finality_type LIKE :finality ESCAPE '\\'";
            $bindings[':finality'] = '%' . $params['finality'] . '%';
        }

        if (!empty($params['keyword'])) {
            $conditions[] = "EXISTS (
                SELECT 1 FROM thesis_tags tt_kw
                JOIN tags tg_kw ON tg_kw.id = tt_kw.tag_id
                WHERE tt_kw.thesis_id = vp.id AND tg_kw.name LIKE :keyword ESCAPE '\\'
            )";
            $bindings[':keyword'] = '%' . $params['keyword'] . '%';
        }

        if (!empty($params['format'])) {
            $conditions[] = "vp.formats LIKE :format ESCAPE '\\'";
            $bindings[':format'] = '%' . $params['format'] . '%';
        }

        if (!empty($params['language'])) {
            $conditions[] = "vp.languages LIKE :language ESCAPE '\\'";
            $bindings[':language'] = '%' . $params['language'] . '%';
        }

        if (isset($params['is_doctoral'])) {
            $conditions[] = "vp.is_doctoral = :is_doctoral";
            $bindings[':is_doctoral'] = $params['is_doctoral'] ? 1 : 0;
        }

        return [$conditions, $bindings];
    }

    /**
     * Search theses with filters (secure implementation)
     */
    public function searchTheses(array $params = [], $limit = 20, $offset = 0) {
        $params = $this->validateSearchParams($params);
        $limit = max(1, min(100, intval($limit)));
        $offset = max(0, intval($offset));

        [$conditions, $bindings] = $this->buildSearchConditions($params);

        $whereClause = implode(' AND ', $conditions);
        $sql = "SELECT vp.* FROM v_theses_public vp WHERE $whereClause ORDER BY vp.year DESC, vp.title ASC LIMIT :limit OFFSET :offset";

        $stmt = $this->pdo->prepare($sql);
        foreach ($bindings as $key => $value) {
            $stmt->bindValue($key, $value);
        }
        $stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
        $stmt->bindValue(':offset', $offset, PDO::PARAM_INT);

        $stmt->execute();
        return $stmt->fetchAll();
    }

    /**
     * Count search results
     */
    public function countSearchResults(array $params = []) {
        $params = $this->validateSearchParams($params);

        [$conditions, $bindings] = $this->buildSearchConditions($params);

        $whereClause = implode(' AND ', $conditions);
        $sql = "SELECT COUNT(*) as count FROM v_theses_public vp WHERE $whereClause";

        $stmt = $this->pdo->prepare($sql);
        foreach ($bindings as $key => $value) {
            $stmt->bindValue($key, $value);
        }

        $stmt->execute();
        $result = $stmt->fetch();
        return $result['count'];
    }

    /**
     * Get all available years from published theses
     */
    /**
     * Return ALL published theses (no cap) — for internal use (student index).
     * Not exposed to user-controlled input.
     *
     * @deprecated Use getPublishedAuthors() for the répertoire student index —
     *             it avoids the expensive v_theses_public view and only fetches
     *             the two columns actually needed (id, authors).
     */
    public function getAllPublishedTheses(): array {
        $stmt = $this->pdo->query(
            "SELECT vp.* FROM v_theses_public vp ORDER BY vp.year DESC, vp.title ASC"
        );
        return $stmt->fetchAll();
    }

    /**
     * Lean query for the répertoire student-name index.
     *
     * Avoids the fat v_theses_public view (15 JOINs + 8 GROUP_CONCAT B-trees).
     * Queries thesis_authors → authors directly, letting the index on
     * theses(is_published) filter the base table before any JOIN.
     *
     * Returns rows of [id => int, authors => "Name1,Name2"].
     */
    public function getPublishedAuthors(): array {
        $stmt = $this->pdo->query(
            "SELECT t.id,
                    GROUP_CONCAT(a.name ORDER BY ta.author_order ASC) AS authors
             FROM theses t
             JOIN thesis_authors ta ON ta.thesis_id = t.id
             JOIN authors a         ON a.id          = ta.author_id
             WHERE t.is_published = 1
             GROUP BY t.id
             ORDER BY MIN(a.name) ASC"
        );
        return $stmt->fetchAll();
    }

    public function getAvailableYears() {
        $sql = "SELECT DISTINCT year FROM theses WHERE is_published = 1 ORDER BY year DESC";
        $stmt = $this->pdo->query($sql);
        return $stmt->fetchAll(PDO::FETCH_COLUMN);
    }

    /**
     * Get all orientations
     */
    public function getAllOrientations(): array {
        $stmt = $this->pdo->query("SELECT * FROM orientations ORDER BY name");
        return $stmt->fetchAll();
    }

    /**
     * Get all AP programs
     */
    public function getAllAPPrograms(): array {
        $stmt = $this->pdo->query("SELECT * FROM ap_programs ORDER BY name");
        return $stmt->fetchAll();
    }

    /**
     * Get all finality types
     */
    public function getAllFinalityTypes(): array {
        $stmt = $this->pdo->query("SELECT * FROM finality_types ORDER BY name");
        return $stmt->fetchAll();
    }

    /**
     * Get all keywords used in published theses
     */
    public function getUsedTags(): array {
        $sql = "SELECT DISTINCT tg.id, tg.name FROM tags tg
                JOIN thesis_tags tt ON tg.id = tt.tag_id
                JOIN theses th ON tt.thesis_id = th.id
                WHERE th.is_published = 1
                ORDER BY tg.name";
        $stmt = $this->pdo->query($sql);
        return $stmt->fetchAll();
    }

    /**
     * Compute répertoire filter data.
     *
     * Given a set of active filters (each an array of values, combined as AND
     * across filter types, OR within each filter type), returns:
     *   - matched_ids   : int[]  thesis IDs matching ALL active filters
     *   - years         : array  all years with matched flag
     *   - ap_programs   : array  all AP programs with matched flag
     *   - orientations  : array  all orientations with matched flag
     *   - finality_types: array  all finality types with matched flag
     *   - keywords      : array  all used keywords with matched flag
     *   - students      : array  [id, authors] rows for matched theses only
     *
     * For each column, "matched" means the value appears in at least one thesis
     * that satisfies all the OTHER active filters (excluding that column's own
     * filter when computing its own relevance).
     *
     * @param array{years:int[], ap:string[], or:string[], fi:string[], kw:string[]} $filters
     */
    public function getRepertoireFilterData(array $filters): array {
        $baseJoins = "
            FROM theses t
            LEFT JOIN orientations o    ON t.orientation_id = o.id
            LEFT JOIN ap_programs ap    ON t.ap_program_id  = ap.id
            LEFT JOIN finality_types ft ON t.finality_id    = ft.id
        ";

        // Build WHERE + bindings excluding one dimension (for that column's own relevance)
        $buildWhere = function(string $exclude) use ($filters): array {
            $conditions = ['t.is_published = 1'];
            $bindings   = [];

            if ($exclude !== 'years' && !empty($filters['years'])) {
                $ph = implode(',', array_fill(0, count($filters['years']), '?'));
                $conditions[] = "t.year IN ($ph)";
                foreach ($filters['years'] as $v) $bindings[] = (int)$v;
            }
            if ($exclude !== 'ap' && !empty($filters['ap'])) {
                $ph = implode(',', array_fill(0, count($filters['ap']), '?'));
                $conditions[] = "ap.name IN ($ph)";
                foreach ($filters['ap'] as $v) $bindings[] = (string)$v;
            }
            if ($exclude !== 'or' && !empty($filters['or'])) {
                $ph = implode(',', array_fill(0, count($filters['or']), '?'));
                $conditions[] = "o.name IN ($ph)";
                foreach ($filters['or'] as $v) $bindings[] = (string)$v;
            }
            if ($exclude !== 'fi' && !empty($filters['fi'])) {
                $ph = implode(',', array_fill(0, count($filters['fi']), '?'));
                $conditions[] = "ft.name IN ($ph)";
                foreach ($filters['fi'] as $v) $bindings[] = (string)$v;
            }
            if ($exclude !== 'kw' && !empty($filters['kw'])) {
                foreach ($filters['kw'] as $kv) {
                    $conditions[] = 'EXISTS (SELECT 1 FROM thesis_tags tt2 JOIN tags tg2 ON tg2.id=tt2.tag_id WHERE tt2.thesis_id=t.id AND tg2.name=?)';
                    $bindings[] = (string)$kv;
                }
            }
            return [implode(' AND ', $conditions), $bindings];
        };

        $exec = function(string $sql, array $b): array {
            $stmt = $this->pdo->prepare($sql);
            $stmt->execute($b);
            return $stmt->fetchAll();
        };

        // Full intersection — matched thesis IDs
        [$wAll, $bAll] = $buildWhere('__none__');
        $matchedIds = array_column($exec("SELECT t.id $baseJoins WHERE $wAll", $bAll), 'id');

        // Years
        [$w, $b] = $buildWhere('years');
        $matchedYears = array_column($exec("SELECT DISTINCT t.year $baseJoins WHERE $w ORDER BY t.year DESC", $b), 'year');
        $allYears = array_column($exec("SELECT DISTINCT year FROM theses WHERE is_published=1 ORDER BY year DESC", []), 'year');
        $yearsOut = array_map(fn($y) => ['value' => $y, 'matched' => in_array($y, $matchedYears, true)], $allYears);

        // AP programs
        [$w, $b] = $buildWhere('ap');
        $matchedAp = array_column($exec("SELECT DISTINCT ap.name $baseJoins WHERE $w AND ap.name IS NOT NULL ORDER BY ap.name", $b), 'name');
        $allAp = array_column($exec("SELECT name FROM ap_programs ORDER BY name", []), 'name');
        $apOut = array_map(fn($n) => ['value' => $n, 'matched' => in_array($n, $matchedAp, true)], $allAp);

        // Orientations
        [$w, $b] = $buildWhere('or');
        $matchedOr = array_column($exec("SELECT DISTINCT o.name $baseJoins WHERE $w AND o.name IS NOT NULL ORDER BY o.name", $b), 'name');
        $allOr = array_column($exec("SELECT name FROM orientations ORDER BY name", []), 'name');
        $orOut = array_map(fn($n) => ['value' => $n, 'matched' => in_array($n, $matchedOr, true)], $allOr);

        // Finality types
        [$w, $b] = $buildWhere('fi');
        $matchedFi = array_column($exec("SELECT DISTINCT ft.name $baseJoins WHERE $w AND ft.name IS NOT NULL ORDER BY ft.name", $b), 'name');
        $allFi = array_column($exec("SELECT name FROM finality_types ORDER BY name", []), 'name');
        $fiOut = array_map(fn($n) => ['value' => $n, 'matched' => in_array($n, $matchedFi, true)], $allFi);

        // Keywords
        [$w, $b] = $buildWhere('kw');
        $matchedKw = array_column($exec(
            "SELECT DISTINCT tg.name $baseJoins
             JOIN thesis_tags tt ON tt.thesis_id = t.id
             JOIN tags tg       ON tg.id = tt.tag_id
             WHERE $w ORDER BY tg.name", $b), 'name');
        $allKw = array_column($exec(
            "SELECT DISTINCT tg.name FROM tags tg
             JOIN thesis_tags tt ON tg.id = tt.tag_id
             JOIN theses th ON tt.thesis_id = th.id
             WHERE th.is_published = 1 ORDER BY tg.name", []), 'name');
        $kwOut = array_map(fn($n) => ['value' => $n, 'matched' => in_array($n, $matchedKw, true)], $allKw);

        // Students (output only — full intersection)
        $studentsOut = [];
        if (!empty($matchedIds)) {
            $ph = implode(',', array_fill(0, count($matchedIds), '?'));
            $studentsOut = $exec(
                "SELECT t.id,
                        GROUP_CONCAT(a.name ORDER BY ta.author_order ASC) AS authors
                 FROM theses t
                 JOIN thesis_authors ta ON ta.thesis_id = t.id
                 JOIN authors a         ON a.id = ta.author_id
                 WHERE t.id IN ($ph)
                 GROUP BY t.id
                 ORDER BY MIN(a.name) ASC",
                $matchedIds
            );
        }

        return [
            'matched_ids'    => $matchedIds,
            'years'          => $yearsOut,
            'ap_programs'    => $apOut,
            'orientations'   => $orOut,
            'finality_types' => $fiOut,
            'keywords'       => $kwOut,
            'students'       => $studentsOut,
        ];
    }

    /**
     * Get all format types
     */
    public function getAllFormatTypes(): array {
        $stmt = $this->pdo->query("SELECT * FROM format_types ORDER BY name");
        return $stmt->fetchAll();
    }

    /**
     * Get all languages
     */
    public function getAllLanguages(): array {
        $stmt = $this->pdo->query("SELECT * FROM languages ORDER BY name");
        return $stmt->fetchAll();
    }

    // ========================================================================
    // ADMIN LIST METHOD
    // ========================================================================

    /**
     * Return theses for the admin list view, with optional filters.
     *
     * Filters (all optional):
     *   'search'      string  – matches title, subtitle, or author name (LIKE)
     *   'year'        int     – exact year match
     *   'orientation' int     – orientation_id exact match
     *
     * @param  array $filters
     * @return array
     */
    /**
     * Allowed sort columns for the admin list.
     * Maps query-string `sort` values to safe SQL ORDER BY expressions.
     */
    private const SORT_MAP = [
        'id'           => 't.id',
        'identifier'   => 't.identifier',
        'title'        => 't.title',
        'year'         => 't.year',
        'orientation'  => 'o.name',
        'ap_program'   => 'ap.name',
        'is_published' => 't.is_published',
        'submitted_at' => 't.submitted_at',
    ];

    /**
     * Build the ORDER BY clause from sort/direction parameters.
     * Returns a safe SQL fragment (never interpolates raw user input).
     */
    private function buildOrderBy(array $filters): string {
        $sort = $filters['sort'] ?? 'submitted_at';
        $dir  = isset($filters['dir']) && strtolower($filters['dir']) === 'asc' ? 'ASC' : 'DESC';

        $col = self::SORT_MAP[$sort] ?? self::SORT_MAP['submitted_at'];

        // Secondary sort for stable ordering
        $secondary = ($sort === 'year') ? ', t.title ASC' : ', t.id DESC';

        return "ORDER BY {$col} {$dir}{$secondary}";
    }

    /**
     * Count theses matching the given admin filters (no LIMIT).
     * Used alongside getThesesList() to calculate total pages.
     */
    public function getThesesListCount(array $filters = []): int {
        $sql = "SELECT COUNT(DISTINCT t.id)
        FROM theses t
        LEFT JOIN orientations o ON t.orientation_id = o.id
        LEFT JOIN ap_programs ap ON t.ap_program_id = ap.id
        LEFT JOIN thesis_authors ta ON t.id = ta.thesis_id
        LEFT JOIN authors a ON ta.author_id = a.id
        WHERE 1=1";

        $params = [];

        if (!empty($filters['search'])) {
            $sql .= " AND (t.title LIKE ? OR t.subtitle LIKE ? OR a.name LIKE ?)";
            $searchParam = '%' . $filters['search'] . '%';
            $params[] = $searchParam;
            $params[] = $searchParam;
            $params[] = $searchParam;
        }

        if (!empty($filters['year'])) {
            $sql .= " AND t.year = ?";
            $params[] = intval($filters['year']);
        }

        if (!empty($filters['orientation'])) {
            $sql .= " AND t.orientation_id = ?";
            $params[] = intval($filters['orientation']);
        }

        if (!empty($filters['ap'])) {
            $sql .= " AND t.ap_program_id = ?";
            $params[] = intval($filters['ap']);
        }

        $stmt = $this->pdo->prepare($sql);
        $stmt->execute($params);
        return (int) $stmt->fetchColumn();
    }

    public function getThesesList(array $filters = [], int $limit = 0, int $offset = 0): array {
        $sql = "SELECT
            t.id, t.identifier, t.title, t.subtitle, t.year,
            o.name as orientation,
            ap.name as ap_program,
            GROUP_CONCAT(DISTINCT a.name) as authors,
            t.submitted_at,
            t.is_published,
            at.name as access_type
        FROM theses t
        LEFT JOIN orientations o ON t.orientation_id = o.id
        LEFT JOIN ap_programs ap ON t.ap_program_id = ap.id
        LEFT JOIN thesis_authors ta ON t.id = ta.thesis_id
        LEFT JOIN authors a ON ta.author_id = a.id
        LEFT JOIN access_types at ON t.access_type_id = at.id
        WHERE 1=1";

        $params = [];

        if (!empty($filters['search'])) {
            $sql .= " AND (t.title LIKE ? OR t.subtitle LIKE ? OR a.name LIKE ?)";
            $searchParam = '%' . $filters['search'] . '%';
            $params[] = $searchParam;
            $params[] = $searchParam;
            $params[] = $searchParam;
        }

        if (!empty($filters['year'])) {
            $sql .= " AND t.year = ?";
            $params[] = intval($filters['year']);
        }

        if (!empty($filters['orientation'])) {
            $sql .= " AND t.orientation_id = ?";
            $params[] = intval($filters['orientation']);
        }

        if (!empty($filters['ap'])) {
            $sql .= " AND t.ap_program_id = ?";
            $params[] = intval($filters['ap']);
        }

        $orderBy = $this->buildOrderBy($filters);
        $sql .= " GROUP BY t.id {$orderBy}";

        if ($limit > 0) {
            $sql .= " LIMIT :limit OFFSET :offset";
        }

        $stmt = $this->pdo->prepare($sql);

        // Bind named params only when LIMIT is used (mix of ? and : is not allowed).
        if ($limit > 0) {
            // Re-bind all positional params by index.
            foreach ($params as $i => $val) {
                $stmt->bindValue($i + 1, $val);
            }
            $stmt->bindValue(':limit',  $limit,  PDO::PARAM_INT);
            $stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
            $stmt->execute();
        } else {
            $stmt->execute($params);
        }

        return $stmt->fetchAll();
    }

    /**
     * Get distinct years present in the theses table (admin, includes unpublished).
     */
    public function getAllYears(): array {
        $stmt = $this->pdo->query("SELECT DISTINCT year FROM theses ORDER BY year DESC");
        return $stmt->fetchAll(PDO::FETCH_COLUMN);
    }

    /**
     * Return whole-database thesis counts, independent of any active filter.
     *
     * Always reflects the full theses table so the stats bar in admin/index.php
     * shows accurate numbers even when a search or year filter is active.
     *
     * @return array{total: int, published: int, pending: int}
     */
    public function getThesesStats(): array {
        $stmt = $this->pdo->query(
            "SELECT
                COUNT(*)                           AS total,
                SUM(CASE WHEN is_published = 1 THEN 1 ELSE 0 END) AS published,
                SUM(CASE WHEN is_published = 0 THEN 1 ELSE 0 END) AS pending
             FROM theses"
        );
        $row = $stmt->fetch();
        return [
            'total'     => (int) $row['total'],
            'published' => (int) $row['published'],
            'pending'   => (int) $row['pending'],
        ];
    }

    // ========================================================================
    // CRUD METHODS (from formulaire)
    // ========================================================================

    /**
     * Find or create an author
     */
    public function findOrCreateAuthor($name, $email = null, bool $showContact = false) {
        $stmt = $this->pdo->prepare("SELECT id FROM authors WHERE name = ?");
        $stmt->execute([$name]);
        $author = $stmt->fetch();

        if ($author) {
            if ($email && $email !== '') {
                $updateStmt = $this->pdo->prepare("UPDATE authors SET email = ?, show_contact = ? WHERE id = ?");
                $updateStmt->execute([$email, $showContact ? 1 : 0, $author['id']]);
            } else {
                $updateStmt = $this->pdo->prepare("UPDATE authors SET show_contact = ? WHERE id = ?");
                $updateStmt->execute([$showContact ? 1 : 0, $author['id']]);
            }
            return $author['id'];
        }

        $stmt = $this->pdo->prepare("INSERT INTO authors (name, email, show_contact) VALUES (?, ?, ?)");
        $stmt->execute([$name, $email, $showContact ? 1 : 0]);
        return $this->pdo->lastInsertId();
    }

    /**
     * Find or create a supervisor
     */
    public function findOrCreateSupervisor($name) {
        $stmt = $this->pdo->prepare("SELECT id FROM supervisors WHERE name = ?");
        $stmt->execute([$name]);
        $supervisor = $stmt->fetch();

        if ($supervisor) {
            return $supervisor['id'];
        }

        $stmt = $this->pdo->prepare("INSERT INTO supervisors (name) VALUES (?)");
        $stmt->execute([$name]);
        return $this->pdo->lastInsertId();
    }

    /**
     * Find or create a tag (formerly keyword)
     */
    public function findOrCreateTag(string $name): ?int {
        $name = trim($name);
        if ($name === '') {
            return null;
        }

        $stmt = $this->pdo->prepare("SELECT id FROM tags WHERE name = ?");
        $stmt->execute([$name]);
        $row = $stmt->fetch();

        if ($row) {
            return (int)$row['id'];
        }

        $stmt = $this->pdo->prepare("INSERT INTO tags (name) VALUES (?)");
        $stmt->execute([$name]);
        return (int)$this->pdo->lastInsertId();
    }



    // ========================================================================
    // TAG MANAGEMENT (admin)
    // ========================================================================

    /**
     * Return all tags with a count of associated (published) theses.
     */
    public function getAllTagsWithCount(): array {
        $stmt = $this->pdo->query("
            SELECT tg.id, tg.name,
                   COUNT(DISTINCT tt.thesis_id) as thesis_count
            FROM tags tg
            LEFT JOIN thesis_tags tt ON tg.id = tt.tag_id
            GROUP BY tg.id
            ORDER BY tg.name COLLATE NOCASE
        ");
        return $stmt->fetchAll();
    }

    /**
     * Rename a tag. Throws if the new name already exists.
     */
    public function renameTag(int $id, string $newName): void {
        $newName = trim($newName);
        if ($newName === '') throw new Exception("Le nom du tag ne peut pas être vide.");
        // Check uniqueness
        $stmt = $this->pdo->prepare("SELECT id FROM tags WHERE name = ? AND id != ?");
        $stmt->execute([$newName, $id]);
        if ($stmt->fetch()) throw new Exception("Un tag avec ce nom existe déjà.");
        $this->pdo->prepare("UPDATE tags SET name = ? WHERE id = ?")->execute([$newName, $id]);
    }

    /**
     * Merge sourceId into targetId: reassign all thesis_tags rows, then delete source.
     * Uses INSERT OR IGNORE to avoid PK conflicts.
     */
    public function mergeTag(int $sourceId, int $targetId): void {
        if ($sourceId === $targetId) throw new Exception("Source et destination identiques.");
        $this->pdo->beginTransaction();
        try {
            // Re-point thesis_tags rows from source → target (skip conflicts)
            $this->pdo->prepare("
                INSERT OR IGNORE INTO thesis_tags (tag_id, thesis_id)
                SELECT ?, thesis_id FROM thesis_tags WHERE tag_id = ?
            ")->execute([$targetId, $sourceId]);
            // Delete the old source rows
            $this->pdo->prepare("DELETE FROM thesis_tags WHERE tag_id = ?")->execute([$sourceId]);
            // Delete the source tag itself
            $this->pdo->prepare("DELETE FROM tags WHERE id = ?")->execute([$sourceId]);
            $this->pdo->commit();
        } catch (\Throwable $e) {
            $this->pdo->rollBack();
            throw $e;
        }
    }

    /**
     * Delete a tag and all its thesis_tags rows (cascades via FK).
     */
    public function deleteTag(int $id): void {
        $this->pdo->prepare("DELETE FROM tags WHERE id = ?")->execute([$id]);
    }

    /**
     * Get orientation ID by name
     */


    // ========================================================================
    // STATIC PAGES METHODS
    // ========================================================================

    /**
     * Get a static page by slug
     * @param string $slug Page slug (e.g. 'about', 'licenses')
     * @return array|null
     */
    public function getPage(string $slug): ?array {
        $stmt = $this->pdo->prepare("SELECT * FROM pages WHERE slug = ?");
        $stmt->execute([$slug]);
        $row = $stmt->fetch();
        return $row ?: null;
    }

    /**
     * Update content for a static page by slug
     * @throws Exception if slug not found
     */
    public function savePage(string $slug, string $content): void {
        $stmt = $this->pdo->prepare("SELECT id FROM pages WHERE slug = ?");
        $stmt->execute([$slug]);
        if (!$stmt->fetch()) {
            throw new Exception("Page slug not found: $slug");
        }
        $stmt = $this->pdo->prepare(
            "UPDATE pages SET content = ?, updated_at = CURRENT_TIMESTAMP WHERE slug = ?"
        );
        $stmt->execute([$content, $slug]);
    }

    /**
     * Get all static pages
     */
    public function getAllPages(): array {
        $stmt = $this->pdo->query("SELECT * FROM pages ORDER BY slug");
        return $stmt->fetchAll();
    }

    // ========================================================================
    // LICENSE TYPE METHODS
    // ========================================================================

    /**
     * Get all license types ordered by name
     */
    public function getAllLicenseTypes(): array {
        $stmt = $this->pdo->query("SELECT * FROM license_types ORDER BY name");
        return $stmt->fetchAll();
    }

    // ========================================================================
    // VISIBILITY METHODS
    // ========================================================================

    /**
     * Set the access_type_id (visibility) for a single thesis.
     * @param int $thesisId
     * @param int|null $accessTypeId  1=Libre, 2=Interne, 3=Interdit, null=unset
     */
    public function setVisibility(int $thesisId, ?int $accessTypeId): void {
        $stmt = $this->pdo->prepare(
            "UPDATE theses SET access_type_id = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?"
        );
        $stmt->execute([$accessTypeId, $thesisId]);
    }

    /**
     * Set visibility for multiple theses at once.
     * @param int[] $thesisIds
     * @param int|null $accessTypeId
     */
    public function bulkSetVisibility(array $thesisIds, ?int $accessTypeId): void {
        if (empty($thesisIds)) return;
        $placeholders = implode(',', array_fill(0, count($thesisIds), '?'));
        $params = array_merge([$accessTypeId], $thesisIds);
        $this->pdo->prepare(
            "UPDATE theses SET access_type_id = ?, updated_at = CURRENT_TIMESTAMP WHERE id IN ($placeholders)"
        )->execute($params);
    }

    /**
     * Set the published state of a single thesis.
     */
    public function setPublished(int $thesisId, bool $published): void {
        $this->pdo->prepare(
            'UPDATE theses SET is_published = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?'
        )->execute([$published ? 1 : 0, $thesisId]);
    }

    /**
     * Set the published state for multiple theses at once.
     * @param int[] $thesisIds
     */
    public function bulkSetPublished(array $thesisIds, bool $published): void {
        if (empty($thesisIds)) return;
        $placeholders = implode(',', array_fill(0, count($thesisIds), '?'));
        $params = array_merge([$published ? 1 : 0], $thesisIds);
        $this->pdo->prepare(
            "UPDATE theses SET is_published = ?, updated_at = CURRENT_TIMESTAMP WHERE id IN ($placeholders)"
        )->execute($params);
    }

    /**
     * Get all access types (visibility options).
     */
    public function getAccessTypes(): array {
        $stmt = $this->pdo->query("SELECT * FROM access_types ORDER BY id");
        return $stmt->fetchAll();
    }

    // ========================================================================
    // SITE SETTINGS
    // ========================================================================

    /**
     * Get a single site setting value by key. Returns $default if not found.
     */
    public function getSetting(string $key, string $default = ''): string {
        $stmt = $this->pdo->prepare("SELECT value FROM site_settings WHERE key = ? LIMIT 1");
        $stmt->execute([$key]);
        $row = $stmt->fetch();
        return $row ? (string) $row['value'] : $default;
    }

    /**
     * Get all site settings as an associative array [ key => value ].
     */
    public function getAllSettings(): array {
        $stmt = $this->pdo->query("SELECT key, value FROM site_settings");
        $rows = $stmt->fetchAll();
        $out = [];
        foreach ($rows as $r) {
            $out[$r['key']] = $r['value'];
        }
        return $out;
    }

    /**
     * Upsert a site setting.
     */
    public function setSetting(string $key, string $value): void {
        $this->pdo->prepare(
            "INSERT INTO site_settings (key, value, updated_at)
             VALUES (?, ?, CURRENT_TIMESTAMP)
             ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP"
        )->execute([$key, $value]);
    }

    /**
     * Return access types that are enabled in the add-thesis form,
     * filtered by site_settings toggles.
     * 'Libre' (id=1) is excluded unless access_type_libre_enabled = '1'.
     * 'Interne' (id=2) is excluded unless access_type_interne_enabled = '1'.
     * 'Interdit' (id=3) is excluded unless access_type_interdit_enabled = '1'.
     */
    public function getEnabledFormAccessTypes(): array {
        $settings = $this->getAllSettings();
        $all = $this->getAccessTypes();
        $map = [
            'Libre'    => $settings['access_type_libre_enabled']    ?? '0',
            'Interne'  => $settings['access_type_interne_enabled']   ?? '1',
            'Interdit' => $settings['access_type_interdit_enabled']  ?? '1',
        ];
        return array_values(array_filter($all, fn($at) => ($map[$at['name']] ?? '0') === '1'));
    }

    /**
     * Update the show_contact flag for the first author of a thesis.
     */
    public function setAuthorShowContact(int $thesisId, bool $show): void {
        $stmt = $this->pdo->prepare(
            "UPDATE authors SET show_contact = ?
             WHERE id = (
                 SELECT author_id FROM thesis_authors
                 WHERE thesis_id = ?
                 ORDER BY author_order LIMIT 1
             )"
        );
        $stmt->execute([$show ? 1 : 0, $thesisId]);
    }

    // ========================================================================
    // JURY METHODS
    // ========================================================================

    /**
     * Fetch all jury members for a thesis, with role and is_external flag.
     */
    public function getThesisJury(int $thesisId): array {
        $stmt = $this->pdo->prepare("
            SELECT s.id, s.name, ts.role, ts.is_external, ts.supervisor_order
            FROM thesis_supervisors ts
            JOIN supervisors s ON s.id = ts.supervisor_id
            WHERE ts.thesis_id = ?
            ORDER BY ts.supervisor_order
        ");
        $stmt->execute([$thesisId]);
        return $stmt->fetchAll();
    }

    /**
     * Replace the entire jury for a thesis in a single transaction.
     * $juryMembers: array of ['name' => string, 'role' => string, 'is_external' => int]
     */
    public function setThesisJury(int $thesisId, array $juryMembers): void {
        $alreadyInTransaction = $this->pdo->inTransaction();
        if (!$alreadyInTransaction) {
            $this->pdo->beginTransaction();
        }
        try {
            $this->pdo->prepare("DELETE FROM thesis_supervisors WHERE thesis_id = ?")->execute([$thesisId]);
            $stmt = $this->pdo->prepare("
                INSERT INTO thesis_supervisors (thesis_id, supervisor_id, role, is_external, supervisor_order)
                VALUES (?, ?, ?, ?, ?)
            ");
            foreach ($juryMembers as $order => $member) {
                $name = trim($member['name'] ?? '');
                if ($name === '') continue;
                $supervisorId = $this->findOrCreateSupervisor($name);
                $role       = in_array($member['role'], ['president', 'promoteur', 'lecteur'])
                                ? $member['role'] : 'promoteur';
                $isExternal = isset($member['is_external']) ? (int)$member['is_external'] : 0;
                $stmt->execute([$thesisId, $supervisorId, $role, $isExternal, $order + 1]);
            }
            if (!$alreadyInTransaction) {
                $this->pdo->commit();
            }
        } catch (\Throwable $e) {
            if (!$alreadyInTransaction && $this->pdo->inTransaction()) {
                $this->pdo->rollBack();
            }
            throw $e;
        }
    }

    // ========================================================================
    // JUNCTION-TABLE HELPERS (delete-then-reinsert pattern)
    // ========================================================================

    /**
     * Replace all language associations for a thesis.
     * @param int   $thesisId
     * @param int[] $languageIds  IDs from the languages table
     */
    public function setThesisLanguages(int $thesisId, array $languageIds): void {
        $this->pdo->prepare("DELETE FROM thesis_languages WHERE thesis_id = ?")->execute([$thesisId]);
        $stmt = $this->pdo->prepare(
            "INSERT INTO thesis_languages (thesis_id, language_id) VALUES (?, ?)"
        );
        foreach ($languageIds as $langId) {
            $id = (int)$langId;
            if ($id > 0) {
                $stmt->execute([$thesisId, $id]);
            }
        }
    }

    /**
     * Replace all format associations for a thesis.
     * @param int   $thesisId
     * @param int[] $formatIds  IDs from the format_types table
     */
    public function setThesisFormats(int $thesisId, array $formatIds): void {
        $this->pdo->prepare("DELETE FROM thesis_formats WHERE thesis_id = ?")->execute([$thesisId]);
        $stmt = $this->pdo->prepare(
            "INSERT INTO thesis_formats (thesis_id, format_id) VALUES (?, ?)"
        );
        foreach ($formatIds as $fmtId) {
            $id = (int)$fmtId;
            if ($id > 0) {
                $stmt->execute([$thesisId, $id]);
            }
        }
    }

    /**
     * Return the list of language IDs currently linked to a thesis.
     * @return int[]
     */
    public function getThesisLanguageIds(int $thesisId): array {
        $stmt = $this->pdo->prepare(
            "SELECT language_id FROM thesis_languages WHERE thesis_id = ?"
        );
        $stmt->execute([$thesisId]);
        return $stmt->fetchAll(PDO::FETCH_COLUMN);
    }

    /**
     * Return the list of format IDs currently linked to a thesis.
     * @return int[]
     */
    public function getThesisFormatIds(int $thesisId): array {
        $stmt = $this->pdo->prepare(
            "SELECT format_id FROM thesis_formats WHERE thesis_id = ?"
        );
        $stmt->execute([$thesisId]);
        return $stmt->fetchAll(PDO::FETCH_COLUMN);
    }

    /**
     * Replace all tag associations for a thesis.
     * Tags are identified by name (findOrCreateTag is called for each).
     * Empty / whitespace-only names are silently skipped.
     * Maximum 10 tags are accepted; extras are ignored.
     *
     * @param int      $thesisId
     * @param string[] $tagNames
     */
    public function setThesisTags(int $thesisId, array $tagNames): void {
        $this->pdo->prepare("DELETE FROM thesis_tags WHERE thesis_id = ?")->execute([$thesisId]);
        $stmt = $this->pdo->prepare(
            "INSERT OR IGNORE INTO thesis_tags (tag_id, thesis_id) VALUES (?, ?)"
        );
        $count = 0;
        foreach ($tagNames as $name) {
            if ($count >= 10) break;
            $tagId = $this->findOrCreateTag($name); // trims, returns null for empty
            if ($tagId !== null) {
                $stmt->execute([$tagId, $thesisId]);
                $count++;
            }
        }
    }

    // ========================================================================
    // BANNER METHODS
    // ========================================================================

    /**
     * Set (or clear) the banner_path for a thesis.
     */
    public function setBannerPath(int $thesisId, ?string $path): void {
        $stmt = $this->pdo->prepare(
            "UPDATE theses SET banner_path = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?"
        );
        $stmt->execute([$path, $thesisId]);
    }

    /**
     * Process a banner image upload for a thesis.
     *
     * Validates MIME type, extension, and file size, then saves the file to the
     * banners/ directory under STORAGE_ROOT and calls setBannerPath().
     *
     * Returns the relative path (e.g. "banners/abc123.jpg") on success,
     * or null if the file array is absent, has an error, fails validation,
     * or cannot be moved.
     *
     * @param int        $thesisId    Target thesis ID
     * @param array|null $uploadedFile  Entry from $_FILES (e.g. $_FILES['banner'])
     * @return string|null  Relative path stored in the DB, or null
     */
    public function handleBannerUpload(int $thesisId, ?array $uploadedFile): ?string {
        if (!$uploadedFile || ($uploadedFile['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
            return null;
        }

        $allowedMimes = ['image/jpeg', 'image/png', 'image/webp'];
        $allowedExts  = ['jpg', 'jpeg', 'png', 'webp'];
        $maxBytes     = 5 * 1024 * 1024; // 5 MB

        $finfo     = new finfo(FILEINFO_MIME_TYPE);
        $mimeType  = $finfo->file($uploadedFile['tmp_name']);
        $ext       = strtolower(pathinfo($uploadedFile['name'], PATHINFO_EXTENSION));

        if (!in_array($mimeType, $allowedMimes, true) ||
            !in_array($ext, $allowedExts, true) ||
            $uploadedFile['size'] > $maxBytes) {
            error_log("handleBannerUpload: rejected " . $uploadedFile['name'] . " ($mimeType, {$uploadedFile['size']} bytes)");
            return null;
        }

        $bannerDir = defined('STORAGE_ROOT') ? STORAGE_ROOT . '/banners/' : null;
        if (!$bannerDir) {
            error_log("handleBannerUpload: STORAGE_ROOT not defined");
            return null;
        }
        if (!file_exists($bannerDir)) {
            mkdir($bannerDir, 0755, true);
        }

        $safeName  = bin2hex(random_bytes(16)) . '.' . $ext;
        $targetPath = $bannerDir . $safeName;

        if (!move_uploaded_file($uploadedFile['tmp_name'], $targetPath)) {
            error_log("handleBannerUpload: move_uploaded_file failed for " . $uploadedFile['name']);
            return null;
        }

        chmod($targetPath, 0644);
        $relativePath = 'banners/' . $safeName;
        $this->setBannerPath($thesisId, $relativePath);
        error_log("handleBannerUpload: saved $relativePath");
        return $relativePath;
    }

    // ========================================================================
    // ENCAPSULATED QUERY HELPERS
    // ========================================================================

    /**
     * Return the raw access_type_id for a thesis (used for visibility gating).
     * Returns null if the thesis is not found.
     */
    public function getThesisAccessTypeId(int $thesisId): ?int {
        $stmt = $this->pdo->prepare(
            "SELECT access_type_id FROM theses WHERE id = ? LIMIT 1"
        );
        $stmt->execute([$thesisId]);
        $val = $stmt->fetchColumn();
        return ($val !== false) ? (int)$val : null;
    }

    /**
     * Return the raw FK fields not exposed through v_theses_full string columns.
     * Returns ['license_id', 'access_type_id', 'context_note'] or null if not found.
     *
     * @return array{license_id:int|null,access_type_id:int|null,context_note:string}|null
     */
    public function getThesisRawFields(int $thesisId): ?array {
        $stmt = $this->pdo->prepare(
            "SELECT license_id, access_type_id, context_note FROM theses WHERE id = ? LIMIT 1"
        );
        $stmt->execute([$thesisId]);
        $row = $stmt->fetch();
        return $row !== false ? $row : null;
    }

    /**
     * Return the banner_path for a thesis, or null.
     * Used when we need just the banner path without the full view expansion.
     */
    public function getThesisBannerPath(int $thesisId): ?string {
        $stmt = $this->pdo->prepare(
            "SELECT banner_path FROM theses WHERE id = ? LIMIT 1"
        );
        $stmt->execute([$thesisId]);
        $val = $stmt->fetchColumn();
        return ($val !== false && $val !== null) ? (string)$val : null;
    }

    /**
     * Batch-load cover file paths for a set of thesis IDs.
     * Returns [thesis_id => file_path] for IDs that have a cover in thesis_files.
     *
     * @param  int[]  $thesisIds
     * @return array<int,string>
     */
    public function getCoverPathsForTheses(array $thesisIds): array {
        if (empty($thesisIds)) {
            return [];
        }
        $placeholders = implode(',', array_fill(0, count($thesisIds), '?'));
        $stmt = $this->pdo->prepare("
            SELECT thesis_id, file_path FROM thesis_files
            WHERE file_type = 'cover' AND thesis_id IN ($placeholders)
        ");
        $stmt->execute($thesisIds);
        $map = [];
        foreach ($stmt->fetchAll() as $row) {
            $map[(int)$row['thesis_id']] = $row['file_path'];
        }
        return $map;
    }

    /**
     * Check visibility for a file path under theses/.
     * Returns the access_type_id of the owning thesis, or null if the file
     * is not found or the path does not belong to a thesis file.
     *
     * Access type 3 = Interdit (forbidden).
     */
    public function getFileVisibility(string $filePath): ?int {
        $stmt = $this->pdo->prepare("
            SELECT t.access_type_id FROM theses t
            JOIN thesis_files tf ON tf.thesis_id = t.id
            WHERE tf.file_path = ?
            LIMIT 1
        ");
        $stmt->execute([$filePath]);
        $val = $stmt->fetchColumn();
        return ($val !== false) ? (int)$val : null;
    }

    /**
     * Return total number of rows in the theses table (for system status display).
     */
    public function getThesisCount(): int {
        return (int)$this->pdo->query("SELECT COUNT(*) FROM theses")->fetchColumn();
    }

    /**
     * Generate a unique YYYY-NNN identifier for a new thesis in the given year.
     * Counts existing theses for that year (published or not) to determine the next sequence
     * number. Must be called inside the same transaction that performs the INSERT so that
     * concurrent requests cannot produce duplicate identifiers.
     */
    public function generateThesisIdentifier(int $year): string {
        $stmt = $this->pdo->prepare("SELECT COUNT(*) FROM theses WHERE year = ?");
        $stmt->execute([$year]);
        $count = (int)$stmt->fetchColumn() + 1;
        return sprintf("%d-%03d", $year, $count);
    }

    /**
     * Insert a new thesis row, link its author, and return the new thesis ID.
     *
     * Expected keys in $data:
     *   year (int), orientation_id (int), ap_program_id (int), finality_id (int),
     *   title (string), subtitle (?string), synopsis (string),
     *   file_size_info (?string), baiu_link (?string), license_id (?int),
     *   author_id (int)
     *
     * The identifier is generated automatically from $data['year'].
     * Must be called inside an open transaction.
     *
     * @return int  The new thesis ID.
     */
    /**
     * Update core thesis fields.
     * All columns except identifier, submitted_at, and file-related fields.
     */
    public function updateThesis(int $thesisId, array $data): void {
        $stmt = $this->pdo->prepare("
            UPDATE theses SET
                title = ?,
                subtitle = ?,
                year = ?,
                orientation_id = ?,
                ap_program_id = ?,
                finality_id = ?,
                synopsis = ?,
                context_note = ?,
                file_size_info = ?,
                baiu_link = ?,
                license_id = ?,
                access_type_id = ?,
                is_published = ?,
                updated_at = CURRENT_TIMESTAMP
            WHERE id = ?
        ");
        $stmt->execute([
            $data['title'],
            !empty($data['subtitle'])      ? $data['subtitle']      : null,
            (int)$data['year'],
            (int)$data['orientation_id'],
            (int)$data['ap_program_id'],
            (int)$data['finality_id'],
            $data['synopsis'],
            !empty($data['context_note'])  ? $data['context_note']  : null,
            !empty($data['file_size_info']) ? $data['file_size_info'] : null,
            !empty($data['baiu_link'])      ? $data['baiu_link']      : null,
            isset($data['license_id'])      ? $data['license_id']     : null,
            isset($data['access_type_id'])  ? $data['access_type_id'] : null,
            $data['is_published'] ? 1 : 0,
            $thesisId,
        ]);
    }

    /**
     * Replace all author associations for a thesis (delete-then-reinsert).
     * $authors is an array of ['name' => string, 'email' => string|null].
     * The first entry is considered the primary author (author_order = 1).
     */
    public function setThesisAuthors(int $thesisId, array $authors): void {
        $this->pdo->prepare("DELETE FROM thesis_authors WHERE thesis_id = ?")->execute([$thesisId]);
        $stmt = $this->pdo->prepare(
            "INSERT INTO thesis_authors (thesis_id, author_id, author_order) VALUES (?, ?, ?)"
        );
        foreach ($authors as $index => $author) {
            $name = trim($author['name'] ?? '');
            if ($name === '') continue;
            $showContact = !empty($author['show_contact']);
            $authorId = $this->findOrCreateAuthor($name, $author['email'] ?? null, $showContact);
            $stmt->execute([$thesisId, $authorId, $index + 1]);
        }
    }

    public function createThesis(array $data): int {
        $identifier = $this->generateThesisIdentifier((int)$data['year']);

        $stmt = $this->pdo->prepare("
            INSERT INTO theses (
                identifier, title, subtitle, year,
                orientation_id, ap_program_id, finality_id,
                synopsis, file_size_info,
                baiu_link, license_id,
                access_type_id,
                is_published,
                submitted_at
            ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, CURRENT_TIMESTAMP)
        ");

        $stmt->execute([
            $identifier,
            $data['title'],
            !empty($data['subtitle']) ? $data['subtitle'] : null,
            (int)$data['year'],
            (int)$data['orientation_id'],
            (int)$data['ap_program_id'],
            (int)$data['finality_id'],
            $data['synopsis'],
            !empty($data['file_size_info']) ? $data['file_size_info'] : null,
            !empty($data['baiu_link'])       ? $data['baiu_link']       : null,
            isset($data['license_id'])       ? $data['license_id']      : null,
            isset($data['access_type_id'])   ? (int)$data['access_type_id'] : 2, // default: Interne
        ]);

        $thesisId = (int)$this->pdo->lastInsertId();

        // Link author — always author_order = 1 for single-author submissions.
        $stmt = $this->pdo->prepare(
            "INSERT INTO thesis_authors (thesis_id, author_id, author_order) VALUES (?, ?, 1)"
        );
        $stmt->execute([$thesisId, (int)$data['author_id']]);

        return $thesisId;
    }

    /**
     * Delete a single thesis and all its related data (cascade via FK).
     * Also removes the banner file from disk if present.
     */
    public function deleteThesis(int $thesisId): void {
        // Clean up banner file
        $bannerPath = $this->getThesisBannerPath($thesisId);
        if ($bannerPath !== null) {
            $fullPath = defined('STORAGE_ROOT') ? STORAGE_ROOT . '/' . $bannerPath : null;
            if ($fullPath && file_exists($fullPath)) {
                @unlink($fullPath);
            }
        }

        // Clean up thesis files from disk
        $files = $this->getThesisFiles($thesisId);
        foreach ($files as $file) {
            if (!empty($file['file_path']) && file_exists($file['file_path'])) {
                @unlink($file['file_path']);
            }
        }

        // DB cascade handles junction tables
        $this->pdo->prepare("DELETE FROM theses WHERE id = ?")->execute([$thesisId]);
    }

    /**
     * Delete multiple theses at once.
     * @param int[] $thesisIds
     */
    public function bulkDeleteTheses(array $thesisIds): void {
        if (empty($thesisIds)) return;

        // Clean up files for each thesis
        foreach ($thesisIds as $id) {
            $bannerPath = $this->getThesisBannerPath($id);
            if ($bannerPath !== null) {
                $fullPath = defined('STORAGE_ROOT') ? STORAGE_ROOT . '/' . $bannerPath : null;
                if ($fullPath && file_exists($fullPath)) {
                    @unlink($fullPath);
                }
            }
            $files = $this->getThesisFiles($id);
            foreach ($files as $file) {
                if (!empty($file['file_path']) && file_exists($file['file_path'])) {
                    @unlink($file['file_path']);
                }
            }
        }

        $placeholders = implode(',', array_fill(0, count($thesisIds), '?'));
        $this->pdo->prepare("DELETE FROM theses WHERE id IN ($placeholders)")->execute($thesisIds);
    }

    /**
     * Return the stored identifier string (e.g. "2024-003") for an existing thesis.
     */
    public function getThesisIdentifier(int $thesisId): string {
        $stmt = $this->pdo->prepare("SELECT identifier FROM theses WHERE id = ?");
        $stmt->execute([$thesisId]);
        $row = $stmt->fetch();
        if (!$row) {
            throw new \RuntimeException("Thesis #$thesisId not found");
        }
        return (string)$row['identifier'];
    }

    /**
     * Delete every thesis in the database.
     */
    public function deleteAllTheses(): int {
        $ids = $this->pdo->query("SELECT id FROM theses")->fetchAll(\PDO::FETCH_COLUMN);
        if (empty($ids)) return 0;
        $count = count($ids);
        $this->bulkDeleteTheses($ids);
        return $count;
    }

    /**
     * Insert a thesis file record
     */
    public function insertThesisFile($thesisId, $fileType, $filePath, $fileName, $fileSize, $mimeType) {
        $stmt = $this->pdo->prepare("
            INSERT INTO thesis_files (thesis_id, file_type, file_path, file_name, file_size, mime_type)
            VALUES (?, ?, ?, ?, ?, ?)
        ");
        $stmt->execute([$thesisId, $fileType, $filePath, $fileName, $fileSize, $mimeType]);
        return $this->pdo->lastInsertId();
    }

    // ========================================================================
    // EXPORT HELPERS — used by ExportController
    // ========================================================================

    /**
     * Fetch all theses (admin — includes unpublished) with every column
     * needed for the CSV export.
     */
    public function getAllThesesForExport(): array {
        return $this->pdo->query("
            SELECT
                t.id, t.identifier, t.title, t.subtitle, t.year,
                o.name       AS orientation,
                ap.name      AS ap_program,
                ft.name      AS finality_type,
                at.name      AS access_type,
                lt.name      AS license_name,
                t.synopsis,
                t.context_note,
                t.remarks,
                t.file_size_info,
                t.jury_points,
                t.baiu_link
            FROM theses t
            LEFT JOIN orientations    o  ON t.orientation_id  = o.id
            LEFT JOIN ap_programs     ap ON t.ap_program_id   = ap.id
            LEFT JOIN finality_types  ft ON t.finality_id     = ft.id
            LEFT JOIN access_types    at ON t.access_type_id  = at.id
            LEFT JOIN license_types   lt ON t.license_id      = lt.id
            ORDER BY t.year DESC, t.title ASC
        ")->fetchAll();
    }

    /**
     * All thesis→author rows with author name and email.
     */
    public function getAllThesisAuthorsForExport(): array {
        return $this->pdo->query("
            SELECT ta.thesis_id, a.name, a.email
            FROM thesis_authors ta
            JOIN authors a ON a.id = ta.author_id
            ORDER BY ta.thesis_id, ta.author_order
        ")->fetchAll();
    }

    /**
     * All thesis→supervisor rows with name.
     */
    public function getAllThesisSupervisorsForExport(): array {
        return $this->pdo->query("
            SELECT ts.thesis_id, s.name
            FROM thesis_supervisors ts
            JOIN supervisors s ON s.id = ts.supervisor_id
            ORDER BY ts.thesis_id, ts.supervisor_order
        ")->fetchAll();
    }

    /**
     * All thesis→tag rows with tag name.
     */
    public function getAllThesisTagsForExport(): array {
        return $this->pdo->query("
            SELECT tt.thesis_id, t.name
            FROM thesis_tags tt
            JOIN tags t ON t.id = tt.tag_id
            ORDER BY tt.thesis_id, t.name
        ")->fetchAll();
    }

    /**
     * All thesis→language rows with language name.
     */
    public function getAllThesisLanguagesForExport(): array {
        return $this->pdo->query("
            SELECT tl.thesis_id, l.name
            FROM thesis_languages tl
            JOIN languages l ON l.id = tl.language_id
            ORDER BY tl.thesis_id, l.name
        ")->fetchAll();
    }

    /**
     * All thesis→format rows with format name.
     */
    public function getAllThesisFormatsForExport(): array {
        return $this->pdo->query("
            SELECT tf.thesis_id, ft.name
            FROM thesis_formats tf
            JOIN format_types ft ON ft.id = tf.format_id
            ORDER BY tf.thesis_id, ft.name
        ")->fetchAll();
    }

    // ========================================================================
    // SINGLETON PATTERN ENFORCEMENT
    // ========================================================================

    // ========================================================================
    // APROPOS CONTENTS (structured data formerly in config/apropos.php)
    // ========================================================================

    /**
     * Get an apropos content value by key.
     * @param string $key 'contacts', 'credits', or 'erg_url'
     * @return array|string|null  JSON-decoded array for contacts/credits, string for erg_url
     */
    public function getAproposContent(string $key) {
        $stmt = $this->pdo->prepare("SELECT value FROM apropos_contents WHERE key = ?");
        $stmt->execute([$key]);
        $row = $stmt->fetch();
        if (!$row) return null;

        $value = $row['value'];
        if ($key === 'erg_url') {
            return $value;
        }
        $decoded = json_decode($value, true);
        return is_array($decoded) ? $decoded : null;
    }

    /**
     * Save an apropos content value by key.
     * @param string $key
     * @param mixed $value  array for contacts/credits, string for erg_url
     */
    public function saveAproposContent(string $key, $value): void {
        $stmt = $this->pdo->prepare("SELECT id FROM apropos_contents WHERE key = ?");
        $stmt->execute([$key]);
        if (!$stmt->fetch()) {
            throw new Exception("Apropos key not found: $key");
        }
        $storedValue = is_array($value) ? json_encode($value, JSON_UNESCAPED_UNICODE) : (string)$value;
        $stmt = $this->pdo->prepare(
            "UPDATE apropos_contents SET value = ?, updated_at = CURRENT_TIMESTAMP WHERE key = ?"
        );
        $stmt->execute([$storedValue, $key]);
    }

    /**
     * Get all apropos contents as [key => value] pairs (raw DB values).
     */
    public function getAllAproposContents(): array {
        $stmt = $this->pdo->query("SELECT key, value, updated_at FROM apropos_contents ORDER BY key");
        return $stmt->fetchAll();
    }

    // ========================================================================
    // SINGLETON PATTERN ENFORCEMENT
    // ========================================================================

    /**
     * Prevent cloning
     */
    private function __clone() {}

    /**
     * Prevent unserialization.
     * PHP 8.x deprecates throwing from __wakeup(); use trigger_error instead.
     */
    public function __wakeup(): void {
        // phpcs:ignore
        trigger_error('Cannot unserialize singleton ' . static::class, E_USER_ERROR);
    }
}
