core/assessment-engine.js

/**
 * Assessment-Engine — Adaptiver Eingangstest.
 * 
 * Wählt Fragen aus dem Katalog aus, passt die Schwierigkeit
 * dynamisch an die Antworten des Nutzers an und berechnet
 * pro Kategorie einen Score (0.0–1.0).
 * 
 * Algorithmus:
 * 1. Startet mit Schwierigkeit 2 (Grundlagen)
 * 2. Bei richtiger Antwort: Schwierigkeit +1 (max 4)
 * 3. Bei falscher Antwort: Schwierigkeit −1 (min 1)
 * 4. Pro Kategorie werden 2–3 Fragen gestellt (adaptiv)
 * 5. Reihenfolge der Kategorien wird gemischt
 * 6. Antwort-Optionen werden pro Frage gemischt
 * 
 * @fileoverview
 * @author Alexander Wolf
 * @version 1.0
 * @see docs/conventions/DATA_MODEL_CONVENTIONS.md §4
 * @see docs/specifications/ONBOARDING_AND_CONTENT_ARCHITECTURE.md
 */

/** Konfiguration */
const CONFIG = {
    /** Fragen pro Kategorie */
    QUESTIONS_PER_CATEGORY: 2,
    /** Bonus-Frage bei gemischten Ergebnissen */
    MAX_QUESTIONS_PER_CATEGORY: 3,
    /** Start-Schwierigkeit */
    START_DIFFICULTY: 2,
    /** Minimale Schwierigkeit */
    MIN_DIFFICULTY: 1,
    /** Maximale Schwierigkeit */
    MAX_DIFFICULTY: 4,
    /** Gesamte Testdauer in Minuten (geschätzt) */
    ESTIMATED_MINUTES: 8
};

/**
 * Adaptiver Assessment-Test.
 * 
 * @example
 * const engine = await AssessmentEngine.create();
 * 
 * while (engine.hasNext()) {
 *     const q = engine.getNextQuestion();
 *     // ... question UI anzeigen ...
 *     const result = engine.submitAnswer(selectedIndex);
 *     // result.correct, result.explanation, result.funFact
 * }
 * 
 * const scores = engine.getResults();
 * // { history: 0.75, math: 0.5, ... }
 */
class AssessmentEngine {

    /**
     * @param {Object} catalog — Geparste assessment-questions.json
     */
    constructor(catalog) {
        /** @type {Object} Rohdaten */
        this._catalog = catalog;

        /** @type {string[]} Gemischte Kategorie-Reihenfolge */
        this._categoryOrder = AssessmentEngine._shuffle([...catalog.categories]);

        /** @type {number} Aktuelle Kategorie (Index in _categoryOrder) */
        this._categoryIndex = 0;

        /** @type {number} Fragen-Zähler in aktueller Kategorie */
        this._questionInCategory = 0;

        /** @type {number} Aktuelle adaptive Schwierigkeit */
        this._currentDifficulty = CONFIG.START_DIFFICULTY;

        /** @type {Map<string, Object[]>} Kategorie → verfügbare Fragen (nach Difficulty gruppiert) */
        this._pool = this._buildPool();

        /** @type {Set<string>} Bereits gestellte Frage-IDs */
        this._asked = new Set();

        /** @type {Object|null} Aktuell angezeigte Frage (mit gemischten Optionen) */
        this._currentQuestion = null;

        /** @type {number[]} Aktuelle Mapping: shuffledIndex → originalIndex */
        this._currentOptionMap = [];

        /** @type {Object<string, Object>} Kategorie → Tracking-Daten */
        this._scores = {};
        for (const cat of catalog.categories) {
            this._scores[cat] = {
                correct: 0,
                total: 0,
                maxDifficultyReached: CONFIG.START_DIFFICULTY,
                answers: []
            };
        }

        /** @type {boolean} Ob der Test beendet ist */
        this._finished = false;

        /** @type {number} Gesamtanzahl gestellter Fragen */
        this._totalAsked = 0;
    }

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

    /**
     * Erstellt eine neue Engine und lädt den Fragenkatalog.
     * 
     * @param {string} [basePath=''] — Basis-Pfad für fetch
     * @returns {Promise<AssessmentEngine>}
     */
    static async create(basePath = '') {
        const sep = basePath && !basePath.endsWith('/') ? '/' : '';
        const url = `${basePath}${sep}config/assessment-questions.json`;
        const response = await fetch(url);
        if (!response.ok) throw new Error(`HTTP ${response.status}`);
        const catalog = await response.json();
        return new AssessmentEngine(catalog);
    }

    /**
     * Erstellt eine Engine mit bereits geladenem Katalog.
     * @param {Object} catalog — Geparste assessment-questions.json
     * @returns {AssessmentEngine}
     */
    static fromCatalog(catalog) {
        return new AssessmentEngine(catalog);
    }

    // ── Navigation API ──────────────────────────────────────────

    /**
     * Gibt zurück, ob noch Fragen übrig sind.
     * @returns {boolean}
     */
    hasNext() {
        if (this._finished) return false;
        return this._categoryIndex < this._categoryOrder.length;
    }

    /**
     * Gibt die nächste Frage zurück (mit gemischten Antwortoptionen).
     * 
     * @returns {Object|null} Frage-Objekt mit:
     *   - id, question, options (gemischt!), category, difficulty
     *   - categoryLabel, progress { current, total, categoryProgress }
     */
    getNextQuestion() {
        if (!this.hasNext()) return null;

        const category = this._categoryOrder[this._categoryIndex];
        const question = this._pickQuestion(category, this._currentDifficulty);

        if (!question) {
            // Keine passenden Fragen mehr in dieser Kategorie → nächste
            this._advanceCategory();
            return this.getNextQuestion();
        }

        this._asked.add(question.id);

        // Optionen mischen
        const { shuffled, map } = AssessmentEngine._shuffleOptions(question.options);
        this._currentOptionMap = map;

        this._currentQuestion = {
            id: question.id,
            question: question.question,
            options: shuffled,
            category: question.category,
            categoryLabel: this._catalog.categoryLabels?.[category] || category,
            difficulty: question.difficulty,
            image: question.image,
            imageCredit: question.imageCredit,
            progress: {
                current: this._totalAsked + 1,
                total: this._estimateTotal(),
                categoryIndex: this._categoryIndex + 1,
                categoryCount: this._categoryOrder.length,
                questionInCategory: this._questionInCategory + 1
            }
        };

        return this._currentQuestion;
    }

    /**
     * Übermittelt die Antwort des Nutzers.
     * 
     * @param {number} selectedIndex — 0-basierter Index der gewählten Option (im gemischten Array)
     * @returns {Object} Ergebnis:
     *   - correct {boolean}
     *   - correctIndex {number} — Index der richtigen Antwort im gemischten Array
     *   - explanation {string}
     *   - funFact {string|null}
     *   - difficulty {number} — Schwierigkeit der beantworteten Frage
     *   - nextDifficulty {number} — Nächste adaptive Schwierigkeit
     */
    submitAnswer(selectedIndex) {
        if (!this._currentQuestion) {
            throw new Error('Keine aktive Frage. Rufe erst getNextQuestion() auf.');
        }

        const question = this._findQuestionById(this._currentQuestion.id);
        const category = question.category;

        // Mapping: gemischter Index → Original-Index
        const originalSelected = this._currentOptionMap[selectedIndex];
        const correct = originalSelected === question.correctIndex;

        // Korrekte Antwort im gemischten Array finden
        const correctShuffledIndex = this._currentOptionMap.indexOf(question.correctIndex);

        // Score tracken
        this._scores[category].total++;
        if (correct) this._scores[category].correct++;
        this._scores[category].maxDifficultyReached = Math.max(
            this._scores[category].maxDifficultyReached,
            question.difficulty
        );
        this._scores[category].answers.push({
            questionId: question.id,
            difficulty: question.difficulty,
            correct
        });

        this._totalAsked++;
        this._questionInCategory++;

        // Adaptive Schwierigkeit anpassen
        const prevDifficulty = this._currentDifficulty;
        if (correct) {
            this._currentDifficulty = Math.min(CONFIG.MAX_DIFFICULTY, this._currentDifficulty + 1);
        } else {
            this._currentDifficulty = Math.max(CONFIG.MIN_DIFFICULTY, this._currentDifficulty - 1);
        }

        // Kategorie-Fortschritt prüfen
        const minReached = this._questionInCategory >= CONFIG.QUESTIONS_PER_CATEGORY;
        const maxReached = this._questionInCategory >= CONFIG.MAX_QUESTIONS_PER_CATEGORY;
        const scoreData = this._scores[category];
        const needsTiebreaker = minReached && !maxReached && 
            scoreData.correct > 0 && scoreData.correct < scoreData.total;

        if (maxReached || (minReached && !needsTiebreaker)) {
            this._advanceCategory();
        }

        this._currentQuestion = null;

        return {
            correct,
            correctIndex: correctShuffledIndex,
            explanation: question.explanation,
            funFact: question.funFact || null,
            difficulty: question.difficulty,
            nextDifficulty: this._currentDifficulty,
            isLastInCategory: this._questionInCategory === 0 // wurde gerade reset
        };
    }

    // ── Results API ─────────────────────────────────────────────

    /**
     * Gibt die Ergebnis-Scores pro Kategorie zurück (0.0–1.0).
     * 
     * Score-Berechnung berücksichtigt nicht nur Korrektheit,
     * sondern auch die erreichte Schwierigkeitsstufe:
     * - Diff 1 richtig = 0.25
     * - Diff 2 richtig = 0.50
     * - Diff 3 richtig = 0.75
     * - Diff 4 richtig = 1.00
     * - Falsche Antworten = 0
     * 
     * @returns {Object<string, number>} Kategorie → Score
     */
    getResults() {
        const results = {};

        for (const [cat, data] of Object.entries(this._scores)) {
            if (data.total === 0) {
                results[cat] = 0;
                continue;
            }

            // Gewichteter Score: korrekte Antworten × Difficulty-Weight
            let weightedSum = 0;
            let maxPossible = 0;

            for (const answer of data.answers) {
                const weight = answer.difficulty * 0.25;
                maxPossible += weight;
                if (answer.correct) {
                    weightedSum += weight;
                }
            }

            results[cat] = maxPossible > 0
                ? Math.round((weightedSum / maxPossible) * 100) / 100
                : 0;
        }

        return results;
    }

    /**
     * Berechnet das Gesamtlevel basierend auf den Scores.
     * @returns {'beginner'|'intermediate'|'advanced'}
     */
    getLevel() {
        const scores = this.getResults();
        const values = Object.values(scores);
        if (values.length === 0) return 'beginner';

        const avg = values.reduce((s, v) => s + v, 0) / values.length;

        if (avg >= 0.7) return 'advanced';
        if (avg >= 0.4) return 'intermediate';
        return 'beginner';
    }

    /**
     * Gibt detaillierte Statistiken zurück.
     * @returns {Object}
     */
    getDetailedStats() {
        return {
            totalQuestions: this._totalAsked,
            scores: this.getResults(),
            level: this.getLevel(),
            perCategory: { ...this._scores },
            estimatedMinutes: CONFIG.ESTIMATED_MINUTES
        };
    }

    /**
     * Gibt empfohlene Themen zurück (basierend auf guten Scores).
     * @returns {string[]} Topic-Tags für die Content-Registry
     */
    getRecommendedTopics() {
        const scores = this.getResults();
        const topics = [];

        // Mapping: Assessment-Kategorien → Content-Registry Topics
        const TOPIC_MAP = {
            'history': ['history'],
            'current-ai': ['current-ai', 'ethics'],
            'general-ai': ['neural-networks', 'rule-systems'],
            'math': ['math-foundations', 'neural-networks'],
            'cs-practice': ['search-trees', 'game-theory', 'heuristics']
        };

        for (const [cat, score] of Object.entries(scores)) {
            if (score >= 0.4 && TOPIC_MAP[cat]) {
                topics.push(...TOPIC_MAP[cat]);
            }
        }

        // Deduplizieren
        return [...new Set(topics)];
    }

    /**
     * Gibt Kategorien zurück, in denen der Nutzer schwach ist.
     * @returns {string[]} Kategorie-Keys mit Score < 0.4
     */
    getWeakCategories() {
        const scores = this.getResults();
        return Object.entries(scores)
            .filter(([, score]) => score < 0.4)
            .map(([cat]) => cat);
    }

    // ── Progress API ────────────────────────────────────────────

    /**
     * Geschätzte Gesamtanzahl der Fragen.
     * @returns {number}
     */
    getTotalEstimate() {
        return this._estimateTotal();
    }

    /**
     * Aktueller Fortschritt als Prozentwert.
     * @returns {number} 0–100
     */
    getProgressPercent() {
        const total = this._estimateTotal();
        return total > 0 ? Math.round((this._totalAsked / total) * 100) : 0;
    }

    /**
     * Ob der Test abgeschlossen ist.
     * @returns {boolean}
     */
    isFinished() {
        return this._finished || !this.hasNext();
    }

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

    /**
     * Baut den Fragen-Pool pro Kategorie × Difficulty.
     * @returns {Map<string, Map<number, Object[]>>}
     * @private
     */
    _buildPool() {
        const pool = new Map();
        for (const cat of this._catalog.categories) {
            const byDiff = new Map();
            for (let d = 1; d <= 4; d++) {
                byDiff.set(d, this._catalog.questions.filter(
                    q => q.category === cat && q.difficulty === d
                ));
            }
            pool.set(cat, byDiff);
        }
        return pool;
    }

    /**
     * Wählt die beste verfügbare Frage für Kategorie + Difficulty.
     * Fällt auf benachbarte Schwierigkeit zurück, falls nötig.
     * @param {string} category
     * @param {number} targetDifficulty
     * @returns {Object|null}
     * @private
     */
    _pickQuestion(category, targetDifficulty) {
        const catPool = this._pool.get(category);
        if (!catPool) return null;

        // Erst genau die Ziel-Schwierigkeit versuchen
        const candidates = this._getAvailable(catPool, targetDifficulty);
        if (candidates.length > 0) {
            return candidates[Math.floor(Math.random() * candidates.length)];
        }

        // Fallback: benachbarte Schwierigkeit (+1, -1, +2, -2)
        for (const offset of [1, -1, 2, -2]) {
            const alt = targetDifficulty + offset;
            if (alt < CONFIG.MIN_DIFFICULTY || alt > CONFIG.MAX_DIFFICULTY) continue;
            const altCandidates = this._getAvailable(catPool, alt);
            if (altCandidates.length > 0) {
                return altCandidates[Math.floor(Math.random() * altCandidates.length)];
            }
        }

        return null;
    }

    /**
     * Gibt verfügbare (noch nicht gestellte) Fragen für eine Difficulty.
     * @param {Map<number, Object[]>} catPool
     * @param {number} difficulty
     * @returns {Object[]}
     * @private
     */
    _getAvailable(catPool, difficulty) {
        const questions = catPool.get(difficulty) || [];
        return questions.filter(q => !this._asked.has(q.id));
    }

    /**
     * Geht zur nächsten Kategorie über.
     * @private
     */
    _advanceCategory() {
        this._categoryIndex++;
        this._questionInCategory = 0;
        if (this._categoryIndex >= this._categoryOrder.length) {
            this._finished = true;
        }
    }

    /**
     * Schätzt die Gesamtanzahl der Fragen.
     * @returns {number}
     * @private
     */
    _estimateTotal() {
        return this._categoryOrder.length * CONFIG.QUESTIONS_PER_CATEGORY;
    }

    /**
     * Findet eine Frage im Katalog anhand der ID.
     * @param {string} id
     * @returns {Object}
     * @private
     */
    _findQuestionById(id) {
        return this._catalog.questions.find(q => q.id === id);
    }

    // ── Static Helpers ──────────────────────────────────────────

    /**
     * Fisher-Yates Shuffle.
     * @param {Array} arr
     * @returns {Array} Neues gemischtes Array
     * @private
     */
    static _shuffle(arr) {
        const a = [...arr];
        for (let i = a.length - 1; i > 0; i--) {
            const j = Math.floor(Math.random() * (i + 1));
            [a[i], a[j]] = [a[j], a[i]];
        }
        return a;
    }

    /**
     * Mischt Antwortoptionen und gibt das Mapping zurück.
     * @param {string[]} options
     * @returns {{ shuffled: string[], map: number[] }}
     *   map[newIndex] = originalIndex
     * @private
     */
    static _shuffleOptions(options) {
        const indices = options.map((_, i) => i);
        const shuffledIndices = AssessmentEngine._shuffle(indices);
        return {
            shuffled: shuffledIndices.map(i => options[i]),
            map: shuffledIndices
        };
    }
}

// Global Export (analog zu iframe-bridge.js)
if (typeof window !== 'undefined') {
    window.AssessmentEngine = AssessmentEngine;
    window.ASSESSMENT_CONFIG = CONFIG;
}