core/content-registry.js

/**
 * Content-Registry — Zentraler Ressourcen-Katalog.
 * 
 * Lädt die Content-Registry aus config/content-registry.json
 * und bietet eine Such- und Filter-API für alle Plattform-Inhalte.
 * 
 * @fileoverview
 * @author Alexander Wolf
 * @version 1.0
 * @see docs/conventions/DATA_MODEL_CONVENTIONS.md §1
 */

/** @type {Object|null} Gecachte Registry-Daten */
let _cache = null;

/** @type {Promise|null} Laufender Ladevorgang */
let _loadPromise = null;

/**
 * Zentraler Zugriffspunkt auf die Content-Registry.
 * Singleton-Pattern: Die Registry wird einmal geladen und gecacht.
 * 
 * @example
 * import { ContentRegistry } from './js/core/content-registry.js';
 * 
 * const registry = await ContentRegistry.load();
 * const games = registry.getByType('game');
 * const resource = registry.getById('game_rotatebox');
 * const searchResults = registry.search('suchbaum');
 */
class ContentRegistry {

    /**
     * @param {Object} data - Geparste content-registry.json
     */
    constructor(data) {
        /** @type {number} Schema-Version */
        this.version = data.version;

        /** @type {Object[]} Alle Ressourcen */
        this.resources = data.resources;

        /** @type {Map<string, Object>} ID → Ressource Lookup */
        this._byId = new Map();

        /** @type {Map<string, Object[]>} Type → Ressourcen Lookup */
        this._byType = new Map();

        this._buildIndices();
    }

    // ── Factory ─────────────────────────────────────────────────

    /**
     * Lädt die Registry aus der JSON-Datei (Singleton, gecacht).
     * 
     * @param {string} [basePath=''] - Optionaler Basis-Pfad für den Fetch 
     *   (z.B. '../' wenn aus html/games/ geladen)
     * @returns {Promise<ContentRegistry>}
     */
    static async load(basePath = '') {
        if (_cache) return _cache;

        if (_loadPromise) return _loadPromise;

        _loadPromise = (async () => {
            // file://-Fallback: window.CONTENT_REGISTRY_DATA nutzen (gesetzt von content-registry-data.js)
            // @see docs/specifications/ES_MODULES_CORS_EVALUATION.md §1.3
            if (typeof window !== 'undefined' && window.CONTENT_REGISTRY_DATA) {
                _cache = new ContentRegistry(window.CONTENT_REGISTRY_DATA);
                return _cache;
            }

            const sep = basePath && !basePath.endsWith('/') ? '/' : '';
            const url = `${basePath}${sep}config/content-registry.json`;

            try {
                const response = await fetch(url);
                if (!response.ok) {
                    throw new Error(`HTTP ${response.status}: ${response.statusText}`);
                }
                const data = await response.json();
                _cache = new ContentRegistry(data);
                return _cache;
            } catch (err) {
                _loadPromise = null;
                console.error('[ContentRegistry] Fehler beim Laden:', err);
                throw err;
            }
        })();

        return _loadPromise;
    }

    /**
     * Setzt den Cache zurück (z.B. für Tests).
     */
    static clearCache() {
        _cache = null;
        _loadPromise = null;
    }

    // ── Query API ───────────────────────────────────────────────

    /**
     * Gibt eine Ressource anhand ihrer ID zurück.
     * @param {string} id - Content-Registry ID
     * @returns {Object|undefined}
     */
    getById(id) {
        return this._byId.get(id);
    }

    /**
     * Gibt alle Ressourcen eines Typs zurück.
     * @param {'game'|'playground'|'expedition'|'challenge-galaxy'|'edu-material'} type
     * @returns {Object[]}
     */
    getByType(type) {
        return this._byType.get(type) || [];
    }

    /**
     * Gibt alle Ressourcen zurück, die einen bestimmten Topic-Tag haben.
     * @param {string} topic - Topic-Tag (z.B. 'search-trees')
     * @returns {Object[]}
     */
    getByTopic(topic) {
        return this.resources.filter(r => r.topics.includes(topic));
    }

    /**
     * Gibt alle Ressourcen zurück, die für ein bestimmtes Level geeignet sind.
     * @param {'beginner'|'intermediate'|'advanced'} level
     * @returns {Object[]}
     */
    getByLevel(level) {
        const maxDifficulty = { beginner: 2, intermediate: 3, advanced: 5 };
        const max = maxDifficulty[level] || 5;
        return this.resources.filter(r => r.difficulty <= max);
    }

    /**
     * Gibt alle Ressourcen zurück, die für eine bestimmte Zielgruppe geeignet sind.
     * @param {'beginner'|'academic'|'student'} audience
     * @returns {Object[]}
     */
    getByAudience(audience) {
        return this.resources.filter(r => 
            r.targetAudience && r.targetAudience.includes(audience)
        );
    }

    /**
     * Gibt alle verwandten Ressourcen einer gegebenen Ressource zurück.
     * @param {string} resourceId - Content-Registry ID
     * @returns {Object[]} Aufgelöste Ressourcen-Objekte
     */
    getRelated(resourceId) {
        const resource = this.getById(resourceId);
        if (!resource || !resource.relatedResources) return [];
        return resource.relatedResources
            .map(id => this.getById(id))
            .filter(Boolean);
    }

    /**
     * Gibt die Voraussetzungen einer Ressource zurück.
     * @param {string} resourceId - Content-Registry ID
     * @returns {Object[]} Aufgelöste Ressourcen-Objekte
     */
    getPrerequisites(resourceId) {
        const resource = this.getById(resourceId);
        if (!resource || !resource.prerequisites) return [];
        return resource.prerequisites
            .map(id => this.getById(id))
            .filter(Boolean);
    }

    /**
     * Volltextsuche über Titel, Beschreibung, Topics und Glossar-Terms.
     * @param {string} query - Suchbegriff (case-insensitive)
     * @returns {Object[]} Passende Ressourcen, sortiert nach Relevanz
     */
    search(query) {
        const q = query.toLowerCase().trim();
        if (!q) return [];

        const results = [];

        for (const r of this.resources) {
            let score = 0;

            // Titel (höchste Gewichtung)
            if (r.title.toLowerCase().includes(q)) score += 10;

            // Beschreibung
            if (r.description.toLowerCase().includes(q)) score += 5;

            // Topics
            if (r.topics.some(t => t.includes(q))) score += 3;

            // Glossar-Terms
            if (r.glossaryTerms && r.glossaryTerms.some(t => t.includes(q))) score += 2;

            // Slug
            if (r.slug.includes(q)) score += 1;

            if (score > 0) {
                results.push({ resource: r, score });
            }
        }

        return results
            .sort((a, b) => b.score - a.score)
            .map(r => r.resource);
    }

    /**
     * Gibt Empfehlungen basierend auf abgeschlossenen Ressourcen und Interessen.
     * @param {Object} options
     * @param {string[]} [options.completedIds=[]] - Bereits abgeschlossene Ressourcen-IDs
     * @param {string[]} [options.interests=[]] - Topic-Tags des Nutzers
     * @param {string[]} [options.dismissed=[]] - Weggeklickte Empfehlungen
     * @param {number} [options.maxResults=5] - Maximale Anzahl Empfehlungen
     * @returns {Object[]} Empfohlene Ressourcen
     */
    getRecommendations({ completedIds = [], interests = [], dismissed = [], maxResults = 5 } = {}) {
        const completedSet = new Set(completedIds);
        const dismissedSet = new Set(dismissed);

        const candidates = this.resources.filter(r => 
            !completedSet.has(r.id) && !dismissedSet.has(r.id)
        );

        const scored = candidates.map(r => {
            let score = 0;

            // Bonus für passende Interessen
            for (const topic of r.topics) {
                if (interests.includes(topic)) score += 5;
            }

            // Bonus, wenn Voraussetzungen erfüllt sind
            if (r.prerequisites.length > 0) {
                const allMet = r.prerequisites.every(id => completedSet.has(id));
                if (allMet) score += 8;
                else score -= 10; // Voraussetzungen nicht erfüllt → deprioritisieren
            }

            // Bonus für verwandte abgeschlossene Ressourcen
            if (r.relatedResources) {
                const relatedCompleted = r.relatedResources.filter(id => completedSet.has(id)).length;
                score += relatedCompleted * 2;
            }

            // Leichter Bonus für niedrigere Schwierigkeit (für Einsteiger)
            if (completedIds.length < 3) {
                score += (5 - r.difficulty);
            }

            return { resource: r, score };
        });

        return scored
            .filter(s => s.score > 0)
            .sort((a, b) => b.score - a.score)
            .slice(0, maxResults)
            .map(s => s.resource);
    }

    /**
     * Gibt alle verfügbaren Topic-Tags zurück.
     * @returns {string[]} Sortierte, deduplizierte Topic-Tags
     */
    getAllTopics() {
        const topics = new Set();
        this.resources.forEach(r => r.topics.forEach(t => topics.add(t)));
        return [...topics].sort();
    }

    /**
     * Gibt die Gesamtzahl der Ressourcen zurück.
     * @returns {number}
     */
    get count() {
        return this.resources.length;
    }

    // ── Private ─────────────────────────────────────────────────

    /**
     * Baut die internen Lookup-Indizes auf.
     * @private
     */
    _buildIndices() {
        for (const resource of this.resources) {
            // ID-Index (immer, damit getById auch für hidden funktioniert)
            this._byId.set(resource.id, resource);

            // Type-Index (hidden-Einträge ausblenden)
            if (!resource.hidden) {
                if (!this._byType.has(resource.type)) {
                    this._byType.set(resource.type, []);
                }
                this._byType.get(resource.type).push(resource);
            }

            // Varianten in den ID-Index aufnehmen
            if (resource.variants) {
                for (const variant of resource.variants) {
                    this._byId.set(variant.id, { ...variant, _parent: resource.id });
                }
            }

            // Challenge-Missionen in den ID-Index aufnehmen
            if (resource.missions) {
                for (const mission of resource.missions) {
                    this._byId.set(mission.id, { ...mission, _parent: resource.id });
                }
            }

            // Expeditions-Kapitel in den ID-Index aufnehmen
            if (resource.chapters) {
                for (const chapter of resource.chapters) {
                    this._byId.set(chapter.id, { ...chapter, _parent: resource.id });
                }
            }
        }
    }
}

// Globale Verfügbarkeit für file://-Kompatibilität (Classic-Script-Pattern)
// @see docs/specifications/ES_MODULES_CORS_EVALUATION.md §1.3
// HINWEIS: export-Statement hier bewusst entfernt — Datei wird als
// klassisches <script src> geladen. ContentRegistry ist via window global verfügbar.
if (typeof window !== 'undefined') {
    window.ContentRegistry = ContentRegistry;
}