"""REST endpoints for AI services — matches frontend service calls."""

import json
import logging
from odoo import http
from odoo.http import request, Response

_logger = logging.getLogger(__name__)


def _json_response(data, status=200):
    return Response(
        json.dumps(data, default=str),
        status=status,
        content_type="application/json",
    )


def _get_json():
    try:
        return json.loads(request.httprequest.data or "{}")
    except Exception:
        return {}


class AIController(http.Controller):
    """Handles /api/ai/* endpoints consumed by frontend AI components."""

    # ── POST /api/ai/search — AiSearchBar.tsx (RAG-enhanced) ──
    @http.route("/api/ai/search", type="http", auth="public", methods=["POST"], csrf=False)
    def ai_search(self, **kw):
        body = _get_json()
        query = body.get("query", "")
        if not query:
            return _json_response({"answer": "", "suggestions": []})
        try:
            from odoo.addons.encoach_ai.services.openai_service import OpenAIService
            ai = OpenAIService(request.env)
            result = ai.search_with_rag(query, context=body.get("context", ""))
            return _json_response(result)
        except Exception as e:
            _logger.exception("AI search failed")
            return _json_response({"answer": f"AI search unavailable: {e}", "suggestions": []})

    # ── GET /api/ai/vector-search — pure semantic search without GPT ──
    @http.route("/api/ai/vector-search", type="http", auth="public", methods=["GET"], csrf=False)
    def ai_vector_search(self, **kw):
        query = request.params.get("q", "")
        content_type = request.params.get("content_type")
        limit = min(int(request.params.get("limit", "10")), 50)
        if not query:
            return _json_response({"results": [], "query": ""})
        try:
            from odoo.addons.encoach_vector.services.embedding_service import EmbeddingService
            svc = EmbeddingService(request.env)
            results = svc.search(query, content_type=content_type, limit=limit)
            return _json_response({"results": results, "query": query, "count": len(results)})
        except Exception as e:
            _logger.exception("Vector search failed")
            return _json_response({"results": [], "query": query, "error": str(e)})

    # ── POST /api/ai/insights — AiInsightsPanel.tsx ──
    @http.route("/api/ai/insights", type="http", auth="public", methods=["POST"], csrf=False)
    def ai_insights(self, **kw):
        body = _get_json()
        try:
            from odoo.addons.encoach_ai.services.openai_service import OpenAIService
            ai = OpenAIService(request.env)
            result = ai.generate_insights(
                body.get("data", {}),
                insight_type=body.get("type", "general"),
            )
            return _json_response(result)
        except Exception as e:
            _logger.exception("AI insights failed")
            return _json_response({"insights": [{"title": "AI Unavailable", "description": str(e), "severity": "info", "recommendation": "Check AI settings."}]})

    # ── GET /api/ai/alerts — AiAlertBanner.tsx ──
    @http.route("/api/ai/alerts", type="http", auth="public", methods=["GET"], csrf=False)
    def ai_alerts(self, **kw):
        try:
            from odoo.addons.encoach_ai.services.openai_service import OpenAIService
            ai = OpenAIService(request.env)
            context = request.params.get("context", "dashboard")
            result = ai.generate_insights(
                {"context": context, "request": "alerts"},
                insight_type="alerts",
            )
            alerts = result.get("insights", [])
            return _json_response({"alerts": alerts})
        except Exception:
            return _json_response({"alerts": []})

    # ── POST /api/ai/report-narrative — AiReportNarrative.tsx ──
    @http.route("/api/ai/report-narrative", type="http", auth="public", methods=["POST"], csrf=False)
    def ai_report_narrative(self, **kw):
        body = _get_json()
        try:
            from odoo.addons.encoach_ai.services.openai_service import OpenAIService
            ai = OpenAIService(request.env)
            narrative = ai.generate_report_narrative(
                body.get("report_type", "performance"),
                body.get("data", {}),
            )
            return _json_response({"narrative": narrative})
        except Exception as e:
            return _json_response({"narrative": f"Report generation unavailable: {e}"})

    # ── POST /api/ai/batch-optimize — AiBatchOptimizer.tsx ──
    @http.route("/api/ai/batch-optimize", type="http", auth="public", methods=["POST"], csrf=False)
    def ai_batch_optimize(self, **kw):
        body = _get_json()
        try:
            from odoo.addons.encoach_ai.services.openai_service import OpenAIService
            ai = OpenAIService(request.env)
            result = ai.batch_optimize(
                body.get("items", []),
                optimization_type=body.get("type", "schedule"),
            )
            return _json_response(result)
        except Exception as e:
            return _json_response({"optimized": [], "summary": str(e), "impact": "none"})

    # ── POST /api/ai/grade-suggest — AiGradingAssistant.tsx ──
    @http.route("/api/ai/grade-suggest", type="http", auth="public", methods=["POST"], csrf=False)
    def ai_grade_suggest(self, **kw):
        body = _get_json()
        try:
            from odoo.addons.encoach_ai.services.openai_service import OpenAIService
            ai = OpenAIService(request.env)
            skill = body.get("skill", "writing")
            if skill == "speaking":
                result = ai.grade_speaking(
                    body.get("rubric", "IELTS Speaking Band Descriptors"),
                    body.get("submission_text", ""),
                )
            else:
                result = ai.grade_writing(
                    body.get("rubric", "IELTS Writing Band Descriptors"),
                    body.get("task", ""),
                    body.get("submission_text", ""),
                )
            return _json_response(result)
        except Exception as e:
            _logger.exception("AI grade suggest failed")
            return _json_response({"scores": {}, "overall_band": 0, "feedback": str(e), "suggestions": []})

    # ── POST /api/ai/generate-resource — ModuleBuilder.tsx (dedup-aware) ──
    @http.route("/api/ai/generate-resource", type="http", auth="public", methods=["POST"], csrf=False)
    def ai_generate_resource(self, **kw):
        body = _get_json()
        try:
            from odoo.addons.encoach_ai.services.openai_service import OpenAIService
            ai = OpenAIService(request.env)
            result = ai.generate_content_dedup(
                body.get("content_type", "reading_passage"),
                body.get("brief", {}),
                cefr_level=body.get("cefr_level", "B2"),
            )
            return _json_response({"resource": result, "status": "generated"})
        except Exception as e:
            return _json_response({"resource": None, "status": "error", "error": str(e)})

    # ── POST /api/ai/detect — GPTZero AI detection ──
    @http.route("/api/ai/detect", type="http", auth="public", methods=["POST"], csrf=False)
    def ai_detect(self, **kw):
        body = _get_json()
        try:
            from odoo.addons.encoach_ai.services.gptzero_service import GPTZeroService
            svc = GPTZeroService(request.env)
            result = svc.detect(body.get("text", ""))
            return _json_response(result)
        except Exception as e:
            return _json_response({"is_ai_generated": False, "ai_probability": 0, "error": str(e)})

    # ── POST /api/plagiarism/check — plagiarism.service.ts ──
    @http.route("/api/plagiarism/check", type="http", auth="public", methods=["POST"], csrf=False)
    def plagiarism_check(self, **kw):
        body = _get_json()
        try:
            from odoo.addons.encoach_ai.services.gptzero_service import GPTZeroService
            svc = GPTZeroService(request.env)
            result = svc.detect(body.get("text", ""))
            report_id = f"plag_{request.env.uid}_{int(__import__('time').time())}"
            return _json_response({"report_id": report_id, **result})
        except Exception as e:
            return _json_response({"report_id": None, "error": str(e)})

    # ── POST /api/domains/:domainId/ai-suggest — TaxonomyManager.tsx ──
    @http.route("/api/domains/<int:domain_id>/ai-suggest", type="http", auth="public", methods=["POST"], csrf=False)
    def ai_suggest_topics(self, domain_id, **kw):
        body = _get_json()
        try:
            from odoo.addons.encoach_ai.services.openai_service import OpenAIService
            ai = OpenAIService(request.env)
            messages = [
                {"role": "system", "content": (
                    "You are an educational taxonomy expert. Suggest topics for the given domain and level. "
                    "Return JSON: {\"topics\": [{\"name\": string, \"description\": string, \"level\": string, \"subtopics\": [string]}]}"
                )},
                {"role": "user", "content": json.dumps({"domain_id": domain_id, **body})},
            ]
            result = ai.chat_json(messages, model=ai.fast_model, action="taxonomy_suggest")
            return _json_response(result)
        except Exception as e:
            return _json_response({"topics": [], "error": str(e)})

    # ── POST /api/learning-plan/generate — LearningPlan.tsx ──
    @http.route("/api/learning-plan/generate", type="http", auth="public", methods=["POST"], csrf=False)
    def learning_plan_generate(self, **kw):
        body = _get_json()
        try:
            from odoo.addons.encoach_ai.services.openai_service import OpenAIService
            ai = OpenAIService(request.env)
            messages = [
                {"role": "system", "content": (
                    "Create a personalized learning plan. Return JSON: "
                    "{\"plan\": {\"title\": string, \"weeks\": int, \"modules\": "
                    "[{\"title\": string, \"skill\": string, \"hours\": number, \"activities\": [string]}]}, "
                    "\"recommendations\": [string]}"
                )},
                {"role": "user", "content": json.dumps(body)},
            ]
            result = ai.chat_json(messages, action="learning_plan")
            return _json_response(result)
        except Exception as e:
            return _json_response({"plan": None, "error": str(e)})

    # ── Workbench endpoints — AiWorkbench.tsx ──
    @http.route("/api/workbench/generate-outline", type="http", auth="public", methods=["POST"], csrf=False)
    def workbench_outline(self, **kw):
        body = _get_json()
        try:
            from odoo.addons.encoach_ai.services.openai_service import OpenAIService
            ai = OpenAIService(request.env)
            messages = [
                {"role": "system", "content": (
                    "Generate a course outline. Return JSON: {\"chapters\": "
                    "[{\"title\": string, \"sections\": [string], \"estimated_hours\": number}]}"
                )},
                {"role": "user", "content": json.dumps(body)},
            ]
            return _json_response(ai.chat_json(messages, action="workbench_outline"))
        except Exception as e:
            return _json_response({"chapters": [], "error": str(e)})

    @http.route("/api/workbench/generate-chapter", type="http", auth="public", methods=["POST"], csrf=False)
    def workbench_chapter(self, **kw):
        body = _get_json()
        try:
            from odoo.addons.encoach_ai.services.openai_service import OpenAIService
            ai = OpenAIService(request.env)
            messages = [
                {"role": "system", "content": (
                    "Generate detailed chapter content for a course. Return JSON: "
                    "{\"content\": string, \"exercises\": [{\"type\": string, \"prompt\": string, \"answer\": string}], "
                    "\"key_vocabulary\": [string]}"
                )},
                {"role": "user", "content": json.dumps(body)},
            ]
            return _json_response(ai.chat_json(messages, action="workbench_chapter", max_tokens=4096))
        except Exception as e:
            return _json_response({"content": "", "error": str(e)})

    @http.route("/api/workbench/generate-rubric", type="http", auth="public", methods=["POST"], csrf=False)
    def workbench_rubric(self, **kw):
        body = _get_json()
        try:
            from odoo.addons.encoach_ai.services.openai_service import OpenAIService
            ai = OpenAIService(request.env)
            messages = [
                {"role": "system", "content": (
                    "Create an assessment rubric. Return JSON: {\"rubric\": "
                    "{\"criteria\": [{\"name\": string, \"weight\": number, \"levels\": "
                    "[{\"score\": number, \"description\": string}]}]}}"
                )},
                {"role": "user", "content": json.dumps(body)},
            ]
            return _json_response(ai.chat_json(messages, action="workbench_rubric"))
        except Exception as e:
            return _json_response({"rubric": None, "error": str(e)})

    @http.route("/api/workbench/regenerate", type="http", auth="public", methods=["POST"], csrf=False)
    def workbench_regenerate(self, **kw):
        return self.workbench_chapter(**kw)

    @http.route("/api/workbench/publish", type="http", auth="public", methods=["POST"], csrf=False)
    def workbench_publish(self, **kw):
        body = _get_json()
        try:
            Module = request.env.get("encoach.course.module")
            if Module:
                Module = Module.sudo()
                chapters = body.get("chapters", [])
                course_id = body.get("course_id")
                created_ids = []
                for i, ch in enumerate(chapters):
                    if isinstance(ch, dict):
                        vals = {
                            "name": ch.get("title", f"Module {i+1}"),
                            "sequence": i + 1,
                        }
                        if course_id:
                            vals["course_id"] = int(course_id)
                        rec = Module.create(vals)
                        created_ids.append(rec.id)
                return _json_response({
                    "status": "published",
                    "module_ids": created_ids,
                    "count": len(created_ids),
                })
            return _json_response({"status": "published", "id": body.get("id")})
        except Exception as e:
            _logger.exception("workbench publish failed")
            return _json_response({"status": "error", "error": str(e)}, 500)

    # ── POST /api/ai/suggest-rubric-criteria — RubricsPage.tsx ──
    @http.route("/api/ai/suggest-rubric-criteria", type="http", auth="public", methods=["POST"], csrf=False)
    def ai_suggest_rubric_criteria(self, **kw):
        from odoo.addons.encoach_api.controllers.base import validate_token
        user = validate_token()
        if not user:
            return _json_response({"error": "Authentication required"}, 401)
        request.update_env(user=user.id)

        body = _get_json()
        name = body.get("name", "")
        skill = body.get("skill", "writing")
        exam_type = body.get("exam_type", "academic")
        levels = body.get("levels", ["A1", "A2", "B1", "B2", "C1", "C2"])

        try:
            from odoo.addons.encoach_ai.services.openai_service import OpenAIService
            ai = OpenAIService(request.env)
            if not ai.client:
                raise RuntimeError("OpenAI not configured")

            band_keys = ", ".join(f'"{lv}"' for lv in levels)
            messages = [
                {"role": "system", "content": (
                    "You are an expert in English language assessment rubric design. "
                    "Generate scoring criteria for a rubric. Return 3-6 criteria.\n\n"
                    "Each criterion must have:\n"
                    "- name: short name (e.g. 'Task Achievement')\n"
                    "- weight: percentage weight (all weights must sum to 100)\n"
                    "- descriptors: an object mapping ONLY these band levels to a 1-sentence description of expected performance at that level\n\n"
                    f"The ONLY allowed band level keys are: {band_keys}\n\n"
                    "Return ONLY this JSON structure:\n"
                    '{"criteria": [{"name": "string", "weight": number, '
                    '"descriptors": {"LEVEL": "one sentence description", ...}}]}'
                )},
                {"role": "user", "content": json.dumps({
                    "rubric_name": name,
                    "skill": skill,
                    "exam_type": exam_type,
                    "target_levels": levels,
                })},
            ]
            result = ai.chat_json(messages, action="suggest_rubric_criteria")
            return _json_response(result)
        except Exception as e:
            _logger.warning("AI unavailable (%s), using template criteria for %s/%s", e, skill, exam_type)
            return _json_response({"criteria": self._fallback_criteria(skill, levels)})

    @staticmethod
    def _fallback_criteria(skill, levels):
        """Return pre-built criteria templates when OpenAI is unavailable."""
        def _desc(level_map, levels):
            return {lv: level_map.get(lv, "") for lv in levels}

        templates = {
            "writing": [
                {"name": "Task Achievement", "weight": 25, "descriptors_map": {
                    "C2": "Fully addresses all parts with a well-developed position",
                    "C1": "Addresses all parts with a clear position throughout",
                    "B2": "Addresses all parts, though some more fully than others",
                    "B1": "Addresses the task only partially with limited development",
                    "A2": "Barely responds to the task with very limited ideas",
                    "A1": "Does not adequately address the task requirements",
                }},
                {"name": "Coherence & Cohesion", "weight": 25, "descriptors_map": {
                    "C2": "Skillfully manages paragraphing with seamless cohesion",
                    "C1": "Logically organizes information with clear progression",
                    "B2": "Arranges information coherently with some cohesive devices",
                    "B1": "Presents information with some organization but may lack clarity",
                    "A2": "Limited ability to organize ideas; unclear progression",
                    "A1": "No apparent logical organization of ideas",
                }},
                {"name": "Lexical Resource", "weight": 25, "descriptors_map": {
                    "C2": "Uses a wide range of vocabulary with very natural and sophisticated control",
                    "C1": "Uses a sufficient range of vocabulary to allow flexibility and precision",
                    "B2": "Uses an adequate range of vocabulary for the task with some errors",
                    "B1": "Uses a limited range of vocabulary with noticeable errors",
                    "A2": "Uses only basic vocabulary with frequent errors in word choice",
                    "A1": "Extremely limited vocabulary; barely able to convey meaning",
                }},
                {"name": "Grammatical Range & Accuracy", "weight": 25, "descriptors_map": {
                    "C2": "Wide range of structures with full flexibility and accuracy",
                    "C1": "Uses a variety of complex structures with good control",
                    "B2": "Uses a mix of simple and complex sentences with some errors",
                    "B1": "Attempts complex sentences but errors are frequent",
                    "A2": "Uses only simple sentences with many errors",
                    "A1": "Cannot use sentence forms except in memorized phrases",
                }},
            ],
            "speaking": [
                {"name": "Fluency & Coherence", "weight": 25, "descriptors_map": {
                    "C2": "Speaks effortlessly with natural flow and fully coherent speech",
                    "C1": "Speaks at length without noticeable effort or loss of coherence",
                    "B2": "Speaks with some hesitation but maintains coherent speech",
                    "B1": "Can keep going but pauses frequently to plan and correct",
                    "A2": "Produces simple utterances with long pauses",
                    "A1": "Speech is extremely slow with very long pauses",
                }},
                {"name": "Lexical Resource", "weight": 25, "descriptors_map": {
                    "C2": "Uses vocabulary with full flexibility and precision in all topics",
                    "C1": "Uses vocabulary flexibly to discuss a variety of topics",
                    "B2": "Has a wide enough vocabulary to discuss topics at length",
                    "B1": "Uses sufficient vocabulary for familiar topics",
                    "A2": "Uses basic vocabulary for personal information and routine situations",
                    "A1": "Can only produce isolated words and memorized phrases",
                }},
                {"name": "Grammatical Range & Accuracy", "weight": 25, "descriptors_map": {
                    "C2": "Maintains consistent use of a wide range of accurate structures",
                    "C1": "Uses a wide range of structures with a majority of error-free sentences",
                    "B2": "Uses a range of structures with reasonable accuracy",
                    "B1": "Produces basic sentence forms with reasonable accuracy",
                    "A2": "Produces basic sentences with frequent errors",
                    "A1": "Cannot produce basic sentence forms",
                }},
                {"name": "Pronunciation", "weight": 25, "descriptors_map": {
                    "C2": "Is effortless to understand with natural pronunciation features",
                    "C1": "Uses a wide range of pronunciation features with fine control",
                    "B2": "Is generally easy to understand with some L1 influence",
                    "B1": "Shows some effective use of features but may be inconsistent",
                    "A2": "Pronunciation is generally understood but often faulty",
                    "A1": "Speech is often unintelligible due to pronunciation errors",
                }},
            ],
        }

        base = templates.get(skill, templates["writing"])
        return [
            {
                "name": c["name"],
                "weight": c["weight"],
                "descriptors": _desc(c["descriptors_map"], levels),
            }
            for c in base
        ]

    # ── Exam generation — GenerationPage.tsx ──
    @http.route("/api/exam/<string:module>/generate", type="http", auth="public", methods=["POST"], csrf=False)
    def exam_generate(self, module, **kw):
        from odoo.addons.encoach_api.controllers.base import validate_token
        user = validate_token()
        if not user:
            return _json_response({"error": "Authentication required"}, 401)
        request.update_env(user=user.id)
        body = _get_json()
        try:
            from odoo.addons.encoach_ai.services.openai_service import OpenAIService
            ai = OpenAIService(request.env)
            has_ai = bool(ai.client)
        except Exception:
            ai, has_ai = None, False

        try:
            if body.get("generate_passage"):
                if has_ai:
                    return self._generate_passage(ai, body)
                return _json_response(self._fallback_passage(body))
            if body.get("generate_instructions"):
                if has_ai:
                    return self._generate_writing_instructions(ai, body)
                return _json_response(self._fallback_writing_instructions(body))
            if body.get("generate_script"):
                if has_ai:
                    return self._generate_speaking_script(ai, body)
                return _json_response(self._fallback_speaking_script(body))
            if body.get("generate_context"):
                if has_ai:
                    return self._generate_listening_context(ai, body)
                return _json_response(self._fallback_listening_context(body))
            if body.get("generate_exercises"):
                if has_ai:
                    return self._generate_exercises(ai, module, body)
                return _json_response(self._fallback_exercises(module, body))

            difficulty = body.get("difficulty", "B2")
            topic = body.get("topic", "")
            count = body.get("count") or body.get("question_count") or 5
            if has_ai:
                messages = [
                    {"role": "system", "content": (
                        f"Generate {count} exam questions for the {module} module at {difficulty} level. "
                        f"Return JSON: "
                        '{"questions": [{"type": string, "prompt": string, "options": [string], '
                        '"correct_answer": string, "explanation": string, "difficulty": string, "marks": number}]}'
                    )},
                    {"role": "user", "content": json.dumps({"topic": topic, "difficulty": difficulty, "count": count, **body})},
                ]
                return _json_response(ai.chat_json(messages, action=f"exam_generate_{module}"))
            return _json_response(self._fallback_questions(module, body))
        except Exception as e:
            _logger.exception("exam_generate %s failed: %s", module, e)
            return _json_response({"questions": [], "error": str(e)}, 500)

    def _get_material_context(self, ai, query, course_id=None, subject_id=None, entity_id=None):
        """Fetch relevant course material / resource context from the vector store for
        RAG-enhanced generation.

        Results are re-ranked to prefer (in order):
        same course_id → same subject_id → same entity_id → everything else.
        """
        try:
            context_results = ai._get_vector_context(
                query,
                content_types=['material', 'resource', 'course', 'topic', 'learning_objective'],
                limit=8,
            )
            if not context_results:
                return ""

            def _score(r):
                meta = r.get('metadata', {}) or {}
                s = 0
                if course_id and meta.get('course_id') == course_id:
                    s += 100
                if subject_id and meta.get('subject_id') == subject_id:
                    s += 50
                if entity_id and meta.get('entity_id') == entity_id:
                    s += 25
                s += float(r.get('similarity', 0) or 0) * 10
                return -s
            context_results.sort(key=_score)
            return ai._format_context(context_results[:6])
        except Exception:
            return ""

    # ── Persona / CEFR helpers for exam generation ────────────────────
    # Concise CEFR descriptors used to give GPT a mental model of each band.
    # Keep these short — they are prepended to every prompt.
    _CEFR_DESCRIPTORS = {
        "A1": "A1 (Breakthrough) — very basic personal information; concrete vocabulary; short fixed phrases; simple present tense; text ~100-200 words with predictable structure.",
        "A2": "A2 (Elementary) — familiar everyday topics; simple past / future; concrete connectors (and, but, because); text ~200-300 words.",
        "B1": "B1 (Threshold) — familiar matters at work/school/leisure; clear standard language; can follow main points; text ~300-400 words; some abstract ideas.",
        "B2": "B2 (Vantage) — complex texts on concrete and abstract topics; including technical discussions in speciality; text ~400-600 words; clear detailed argument.",
        "C1": "C1 (Effective Operational Proficiency) — wide range of demanding long texts; implicit meaning; flexible and effective language use; nuanced argument; text ~600-900 words; idiomatic expressions; complex sub-clauses.",
        "C2": "C2 (Mastery) — understand virtually everything with ease; reconstruct arguments; precise shades of meaning; text ~800-1200 words; sophisticated stylistic choices.",
    }

    # Examiner / item-writer personas keyed by module + exam_mode.
    _EXAM_MODE_LABEL = {
        "official": "official high-stakes IELTS exam (summative, ranked Band 0-9)",
        "practice": "practice / formative exam (low-stakes, used for learner feedback)",
    }

    def _persona_for(self, module, exam_mode="official", exam_type="academic"):
        mode = self._EXAM_MODE_LABEL.get(exam_mode, "standardised English exam")
        et = (exam_type or "academic").lower()
        et_pretty = {
            "academic": "IELTS Academic",
            "general": "IELTS General Training",
            "general_training": "IELTS General Training",
            "business": "Business English",
            "ket": "Cambridge KET", "pet": "Cambridge PET",
            "fce": "Cambridge FCE", "cae": "Cambridge CAE", "cpe": "Cambridge CPE",
        }.get(et, et.title())
        base = f"You are a senior {et_pretty} item writer preparing content for a {mode}."
        extras = {
            "reading": " You specialise in CEFR-aligned reading passages whose lexical range, grammatical complexity, cohesion devices, and topic sophistication match the target band exactly.",
            "listening": " You specialise in CEFR-aligned listening scripts with realistic features (hesitations, discourse markers, accents) while staying lexically and grammatically appropriate to the target band.",
            "writing": " You specialise in CEFR-aligned writing prompts that elicit the task response, lexical range, grammatical range and coherence expected at the target band.",
            "speaking": " You specialise in CEFR-aligned speaking cue cards and examiner scripts whose follow-up questions elicit the fluency, lexical resource, grammatical range and pronunciation expected at the target band.",
        }
        return base + extras.get(module, "")

    def _build_persona_context(self, body):
        """Format the user-captured parameters into a readable 'exam brief' block
        that the LLM sees as a dedicated system message."""
        rows = []

        def _add(label, value):
            if value in (None, "", [], {}):
                return
            if isinstance(value, list):
                value = ", ".join(str(v) for v in value if v not in (None, ""))
                if not value:
                    return
            rows.append(f"- {label}: {value}")

        _add("Exam title", body.get("exam_title") or body.get("title"))
        _add("Exam label", body.get("exam_label") or body.get("label"))
        _add("Exam mode", body.get("exam_mode"))
        _add("Exam structure", body.get("structure_name") or body.get("structure"))
        _add("Module", body.get("module"))
        _add("CEFR target level", body.get("difficulty"))
        _add("Passage category", body.get("category"))
        _add("Passage type", body.get("passage_type") or body.get("type"))
        _add("Task type", body.get("task_type"))
        _add("Speaking part", body.get("part"))
        _add("Listening section type", body.get("section_type"))
        _add("Target word count", body.get("word_count"))
        _add("Subject", body.get("subject_name") or body.get("subject"))
        _add("Topic", body.get("topic") or body.get("topics"))
        _add("Entity / Organisation", body.get("entity_name"))
        _add("Rubric", body.get("rubric_name"))
        _add("Grading system", body.get("grading_system"))
        _add("Access type", body.get("access_type"))

        if not rows:
            return ""
        return "EXAM BRIEF — follow these parameters strictly:\n" + "\n".join(rows)

    def _build_rag_query(self, body, fallback=""):
        """Combine topic / category / type / subject / objective into a single
        semantic query for vector RAG (much more specific than just 'topic')."""
        parts = []
        for key in ("topic", "topics", "category", "passage_type", "type",
                    "task_type", "section_type", "subject_name", "module"):
            v = body.get(key)
            if isinstance(v, list):
                parts.extend(str(x) for x in v if x)
            elif v:
                parts.append(str(v))
        if not parts and fallback:
            parts.append(fallback)
        return " ".join(parts)[:400]

    def _generate_passage(self, ai, body):
        topic = body.get("topic", "general knowledge")
        difficulty = body.get("difficulty", "B2")
        word_count = int(body.get("word_count") or 300)
        passage_type = (body.get("passage_type") or body.get("type") or "academic").lower()
        category = body.get("category", "")
        exam_mode = body.get("exam_mode", "official")
        course_id = body.get("course_id")
        subject_id = body.get("subject_id")
        entity_id = body.get("entity_id")

        persona = self._persona_for("reading", exam_mode, passage_type)
        persona_ctx = self._build_persona_context({**body, "module": "reading"})
        cefr = self._CEFR_DESCRIPTORS.get(difficulty, "")

        rag_query = self._build_rag_query(body, fallback=topic)
        material_context = self._get_material_context(
            ai, rag_query, course_id=course_id, subject_id=subject_id, entity_id=entity_id
        )

        system_parts = [persona]
        if cefr:
            system_parts.append("CEFR TARGET LEVEL\n" + cefr)
        system_parts.append(
            f"TASK\nWrite ONE {passage_type} reading passage of approximately {word_count} words "
            f"(±10%). The passage must be authentic-feeling, coherent, and pitched exactly at "
            f"CEFR {difficulty}. Sentence length, clause complexity, lexical range and cohesion "
            f"markers must match the target band. Avoid regional slang. Do NOT mention CEFR, IELTS "
            f"or grading inside the passage itself."
        )
        if category:
            system_parts.append(f"DOMAIN / CATEGORY\nThe passage must be grounded in the domain: '{category}'.")
        system_parts.append(
            "OUTPUT CONTRACT — return ONLY this JSON:\n"
            '{"title": "short catchy title (<=10 words)",'
            ' "passage": "the full passage text, use \\n\\n between paragraphs",'
            ' "paragraph_count": int,'
            ' "approx_word_count": int,'
            ' "key_vocabulary": [string, ...],'
            ' "rhetorical_structure": "e.g. problem-solution / compare-contrast / chronological",'
            ' "cefr_level": "' + difficulty + '"}'
        )

        messages = [{"role": "system", "content": "\n\n".join(system_parts)}]
        if persona_ctx:
            messages.append({"role": "system", "content": persona_ctx})
        if material_context:
            messages.append({"role": "system", "content": (
                "REFERENCE MATERIAL — use these curated EnCoach resources to ground the "
                "passage in what learners have been studying. Match terminology, examples and "
                "scope; DO NOT copy verbatim:\n\n" + material_context
            )})
        messages.append({"role": "user", "content": f"Topic: {topic}"})
        return _json_response(ai.chat_json(messages, action="generate_passage"))

    def _generate_writing_instructions(self, ai, body):
        topic = body.get("topic", "general")
        difficulty = body.get("difficulty", "B2")
        task_type = body.get("task_type", "essay")
        exam_mode = body.get("exam_mode", "official")
        exam_type = (body.get("passage_type") or body.get("type") or "academic").lower()
        word_limit = int(body.get("word_limit") or (250 if task_type == "essay" else 150))
        course_id = body.get("course_id")
        subject_id = body.get("subject_id")
        entity_id = body.get("entity_id")
        rubric_name = body.get("rubric_name", "")

        persona = self._persona_for("writing", exam_mode, exam_type)
        persona_ctx = self._build_persona_context({**body, "module": "writing"})
        cefr = self._CEFR_DESCRIPTORS.get(difficulty, "")

        rag_query = self._build_rag_query(body, fallback=topic)
        material_context = self._get_material_context(
            ai, rag_query, course_id=course_id, subject_id=subject_id, entity_id=entity_id
        )

        sys = [persona]
        if cefr:
            sys.append("CEFR TARGET LEVEL\n" + cefr)
        sys.append(
            f"TASK\nWrite the STUDENT-FACING instructions for a '{task_type}' writing task at "
            f"CEFR {difficulty}. The task must be completable in roughly {word_limit} words. "
            "Use a neutral, authoritative exam register. Include any bullet-point sub-prompts a "
            "student needs to address (e.g. 'explain', 'describe', 'suggest'). Do NOT write the "
            "model answer — only the instructions."
        )
        if rubric_name:
            sys.append(
                f"RUBRIC AWARENESS\nThe student answer will be graded with the rubric "
                f"'{rubric_name}'. Frame the instructions so that the task naturally elicits the "
                "criteria that rubric evaluates (task response, coherence, lexical resource, "
                "grammatical range)."
            )
        sys.append(
            "OUTPUT CONTRACT — return ONLY this JSON:\n"
            '{"instructions": "the full student-facing instructions (plain text, \\n for line breaks)",'
            ' "suggested_word_limit": int,'
            ' "evaluation_focus": [string, ...],'
            ' "cefr_level": "' + difficulty + '"}'
        )

        messages = [{"role": "system", "content": "\n\n".join(sys)}]
        if persona_ctx:
            messages.append({"role": "system", "content": persona_ctx})
        if material_context:
            messages.append({"role": "system", "content": (
                "REFERENCE MATERIAL — ground the task in topics students have studied:\n\n"
                + material_context
            )})
        messages.append({"role": "user", "content": f"Topic: {topic}"})
        return _json_response(ai.chat_json(messages, action="generate_writing_instructions"))

    def _generate_speaking_script(self, ai, body):
        topics = body.get("topics", [])
        difficulty = body.get("difficulty", "B1")
        part = body.get("part", "speaking_1")
        exam_mode = body.get("exam_mode", "official")
        exam_type = (body.get("passage_type") or body.get("type") or "academic").lower()
        course_id = body.get("course_id")
        subject_id = body.get("subject_id")
        entity_id = body.get("entity_id")

        topic_str = ", ".join(t for t in topics if t) if topics else (body.get("topic") or "general conversation")

        persona = self._persona_for("speaking", exam_mode, exam_type)
        persona_ctx = self._build_persona_context({**body, "module": "speaking", "topics": topics, "topic": topic_str})
        cefr = self._CEFR_DESCRIPTORS.get(difficulty, "")

        rag_query = self._build_rag_query(body, fallback=topic_str)
        material_context = self._get_material_context(
            ai, rag_query, course_id=course_id, subject_id=subject_id, entity_id=entity_id
        )

        part_guidance = {
            "speaking_1": "Part 1 — Introduction & Interview: 4-6 short personal warm-up questions (15-30s answers).",
            "speaking_2": "Part 2 — Individual Long Turn: ONE cue card with 3-4 bullet points; 1 min prep, 1-2 min monologue; 2-3 examiner follow-ups.",
            "speaking_3": "Part 3 — Two-way Discussion: 4-6 abstract/analytical questions linked to Part 2 theme (45-60s answers).",
            "interactive": "Interactive role-play: a realistic conversational scenario with turns labelled.",
        }.get(part, "Mixed speaking prompts")

        sys = [persona]
        if cefr:
            sys.append("CEFR TARGET LEVEL\n" + cefr)
        sys.append("TASK\n" + part_guidance + (
            f" Level-pitch the questions so a {difficulty} candidate is genuinely stretched "
            "but not overwhelmed. Label every line as 'Examiner:' or 'Candidate:' (for examples) "
            "or keep to 'Examiner:' only if you prefer. Use neutral British English register."
        ))
        sys.append(
            "OUTPUT CONTRACT — return ONLY this JSON:\n"
            '{"script": "the full examiner script (plain text, use \\n for line breaks)",'
            ' "follow_up_questions": [string, ...],'
            ' "cue_card_bullets": [string, ...]  // only for speaking_2, else [],'
            ' "cefr_level": "' + difficulty + '"}'
        )

        messages = [{"role": "system", "content": "\n\n".join(sys)}]
        if persona_ctx:
            messages.append({"role": "system", "content": persona_ctx})
        if material_context:
            messages.append({"role": "system", "content": (
                "REFERENCE MATERIAL — anchor the prompts in themes the candidate has studied:\n\n"
                + material_context
            )})
        messages.append({"role": "user", "content": f"Topics: {topic_str}"})
        return _json_response(ai.chat_json(messages, action="generate_speaking_script"))

    def _generate_listening_context(self, ai, body):
        topic = body.get("topic", "everyday life")
        section_type = body.get("section_type", "social_conversation")
        difficulty = body.get("difficulty", "B1")
        exam_mode = body.get("exam_mode", "official")
        exam_type = (body.get("passage_type") or body.get("type") or "academic").lower()
        course_id = body.get("course_id")
        subject_id = body.get("subject_id")
        entity_id = body.get("entity_id")

        persona = self._persona_for("listening", exam_mode, exam_type)
        persona_ctx = self._build_persona_context({**body, "module": "listening"})
        cefr = self._CEFR_DESCRIPTORS.get(difficulty, "")

        rag_query = self._build_rag_query(body, fallback=topic)
        material_context = self._get_material_context(
            ai, rag_query, course_id=course_id, subject_id=subject_id, entity_id=entity_id
        )

        section_guidance = {
            "social_conversation": "two speakers in a day-to-day social context (2-4 min); include natural discourse markers, hesitations and back-channels.",
            "social_monologue": "one speaker in a non-academic context, e.g. tour, announcement, instructions (2-3 min).",
            "academic_conversation": "2-4 speakers in a university / study context (seminar, tutorial).",
            "academic_lecture": "one lecturer delivering a ~4 min academic talk with clear signposting.",
        }.get(section_type, f"a {section_type.replace('_', ' ')}")

        sys = [persona]
        if cefr:
            sys.append("CEFR TARGET LEVEL\n" + cefr)
        sys.append(
            "TASK\nWrite a listening transcript for " + section_guidance +
            f" Pitch vocabulary, speed implications and idiomaticity to CEFR {difficulty}. "
            "Label EVERY turn with a speaker tag (e.g. 'Presenter:', 'Student A:', 'Tutor:'). "
            "Include realistic features like fillers ('er', 'you know'), self-corrections, "
            "and conversational overlap where appropriate — but stay intelligible."
        )
        sys.append(
            "OUTPUT CONTRACT — return ONLY this JSON:\n"
            '{"context": "the full labelled transcript",'
            ' "speakers": [{"label": string, "description": string}],'
            ' "approx_duration_seconds": int,'
            ' "cefr_level": "' + difficulty + '"}'
        )

        messages = [{"role": "system", "content": "\n\n".join(sys)}]
        if persona_ctx:
            messages.append({"role": "system", "content": persona_ctx})
        if material_context:
            messages.append({"role": "system", "content": (
                "REFERENCE MATERIAL — weave in concepts students have encountered:\n\n"
                + material_context
            )})
        messages.append({"role": "user", "content": f"Topic: {topic}"})
        return _json_response(ai.chat_json(messages, action="generate_listening_context"))

    def _generate_exercises(self, ai, module, body):
        passage_text = body.get("passage_text", "")
        exercise_types = body.get("exercise_types", [])
        type_counts = body.get("type_counts", {})
        type_instructions = body.get("type_instructions", {})
        type_difficulties = body.get("type_difficulties", {}) or {}
        default_count = body.get("count_per_type", 5)
        difficulty = body.get("difficulty", "B2")
        exam_mode = body.get("exam_mode", "official")
        exam_type = (body.get("passage_type") or body.get("type") or "academic").lower()
        course_id = body.get("course_id")
        subject_id = body.get("subject_id")
        entity_id = body.get("entity_id")

        persona = self._persona_for(module if module in ("reading", "listening") else "reading",
                                    exam_mode, exam_type)
        persona_ctx = self._build_persona_context({**body, "module": module})
        cefr = self._CEFR_DESCRIPTORS.get(difficulty, "")

        rag_query = self._build_rag_query(body, fallback=passage_text[:200])
        material_context = self._get_material_context(
            ai, rag_query, course_id=course_id, subject_id=subject_id, entity_id=entity_id
        )

        type_specs = []
        total = 0
        level_set = set()
        for et in exercise_types:
            c = int(type_counts.get(et, default_count))
            instr = type_instructions.get(et, "")
            lvl = type_difficulties.get(et) or difficulty
            level_set.add(lvl)
            spec_line = f"- EXACTLY {c} questions of type \"{et}\" pitched at CEFR {lvl}"
            if instr:
                spec_line += f"\n  Student instructions: \"{instr}\""
            type_specs.append(spec_line)
            total += c
        spec_str = "\n".join(type_specs) if type_specs else f"- {default_count} multiple choice questions at CEFR {difficulty}"

        extra_cefr_block = ""
        if len(level_set) > 1:
            extra_cefr_block = "PER-TYPE CEFR LEVELS\n" + "\n".join(
                f"- {lvl}: {self._CEFR_DESCRIPTORS.get(lvl, '')}" for lvl in sorted(level_set)
            )

        sys = [persona]
        if cefr:
            sys.append("CEFR TARGET LEVEL\n" + cefr)
        if extra_cefr_block:
            sys.append(extra_cefr_block)
        # How many items should require inferential/higher-order thinking
        higher_order_target = 0
        if difficulty in ("B2", "C1", "C2"):
            higher_order_target = max(1, total // 3)
        sys.append(
            f"TASK\nWrite EXACTLY {total} exam questions based strictly on the source text below. "
            "Every question MUST be answerable from that text alone — no outside knowledge. "
            "Distractors for MCQs must be plausible, drawn from or consistent with the text, and of "
            "roughly equal length and grammatical form. Do not repeat stems. Vary cognitive load: "
            "include both literal-retrieval and inferential items."
            + (f" At least {higher_order_target} of the {total} items MUST require inference, "
               "implication or synthesis rather than literal lookup." if higher_order_target else "")
            + f"\n\nREQUIRED BREAKDOWN (respect counts and per-type CEFR exactly):\n{spec_str}"
        )
        sys.append(
            "TYPE RULES\n"
            "1. mcq: 4 options, exactly one correct, 'options' is an array of strings, "
            "'correct_answer' is the exact string of the correct option.\n"
            "2. true_false: options = ['TRUE','FALSE','NOT GIVEN']; answer is one of those.\n"
            "3. fill_blanks / write_blanks: put '___' inside 'prompt' where the blank is, "
            "'correct_answer' is the filler word(s) taken verbatim from the text, 'options' = [].\n"
            "4. paragraph_match / matching_headings: 'prompt' is the statement/heading, "
            "'options' is the list of paragraph labels, 'correct_answer' is the correct label.\n"
            "5. short_answer / summary_completion: 'options' = [], 'correct_answer' is the expected answer.\n"
            "6. Every question MUST include 'source_paragraph' (1-indexed number of the paragraph "
            "the answer is drawn from) and a non-empty 'explanation' that quotes or paraphrases "
            "the supporting span from that paragraph."
        )
        sys.append(
            "OUTPUT CONTRACT — return ONLY this JSON:\n"
            '{"questions": [{"type": string, "instructions": string, "prompt": string, '
            '"options": [string], "correct_answer": string, "explanation": string, '
            '"source_paragraph": int, "cognitive_level": "literal"|"inferential"|"evaluative", '
            '"marks": number, "cefr_level": string}]}'
        )

        messages = [{"role": "system", "content": "\n\n".join(sys)}]
        if persona_ctx:
            messages.append({"role": "system", "content": persona_ctx})
        if material_context:
            messages.append({"role": "system", "content": (
                "REFERENCE MATERIAL — keep terminology and scope consistent with curriculum:\n\n"
                + material_context
            )})
        messages.append({
            "role": "user",
            "content": "SOURCE TEXT (authoritative — every answer must come from here):\n\n"
                       + (passage_text[:3000] or "(no source text provided)")
        })
        return _json_response(ai.chat_json(messages, action=f"generate_exercises_{module}"))

    # ── Fallback generators (no OpenAI needed) ──

    @staticmethod
    def _fallback_passage(body):
        topic = body.get("topic", "travel")
        difficulty = body.get("difficulty", "B2")
        templates = {
            "travel": {
                "title": "The Rise of Sustainable Tourism",
                "passage": (
                    "In recent years, sustainable tourism has emerged as a powerful movement reshaping how "
                    "people explore the world. Unlike traditional mass tourism, which often prioritises "
                    "convenience and cost over environmental impact, sustainable tourism encourages travellers "
                    "to consider their ecological footprint and cultural sensitivity.\n\n"
                    "The concept gained significant traction after international organisations highlighted the "
                    "devastating effects of unchecked tourism on fragile ecosystems. Coral reefs in Southeast "
                    "Asia, ancient ruins in South America, and wildlife reserves in Africa have all suffered "
                    "from overcrowding, pollution, and habitat destruction caused by the influx of visitors.\n\n"
                    "Governments and local communities have responded by implementing measures such as visitor "
                    "caps, eco-certification programmes, and community-based tourism initiatives. In Bhutan, "
                    "for example, the government charges a daily sustainable development fee to limit tourist "
                    "numbers while funding conservation efforts and education.\n\n"
                    "Tour operators have also adapted their business models. Many now offer carbon-offset "
                    "programmes, partner with local artisans and guides, and design itineraries that minimise "
                    "environmental disruption. Accommodation providers have invested in solar energy, rainwater "
                    "harvesting, and waste-reduction systems.\n\n"
                    "Despite these positive developments, challenges remain. Critics argue that sustainable "
                    "tourism can be exclusionary, pricing out budget travellers and local residents. Others "
                    "point out that certification schemes vary widely in rigour and transparency. Nevertheless, "
                    "the growing awareness among travellers suggests that the industry is moving in the right "
                    "direction, balancing economic growth with environmental stewardship."
                ),
            },
            "technology": {
                "title": "Artificial Intelligence in Everyday Life",
                "passage": (
                    "Artificial intelligence, once confined to research laboratories and science fiction, has "
                    "become an integral part of daily life. From voice-activated assistants on smartphones to "
                    "recommendation algorithms on streaming platforms, AI systems now influence many of the "
                    "choices people make without them even realising it.\n\n"
                    "One of the most visible applications of AI is in healthcare. Machine learning algorithms "
                    "can now analyse medical images with remarkable accuracy, sometimes identifying conditions "
                    "such as early-stage cancers that human radiologists might miss. Hospitals around the world "
                    "are adopting AI-powered tools for patient triage, drug discovery, and personalised "
                    "treatment planning.\n\n"
                    "In education, AI-driven platforms adapt learning content to individual student needs. These "
                    "systems monitor a learner's progress and adjust the difficulty and style of materials "
                    "accordingly, providing a customised experience that traditional classroom settings often "
                    "cannot match.\n\n"
                    "However, the rapid adoption of AI has raised important ethical questions. Issues of data "
                    "privacy, algorithmic bias, and job displacement have sparked intense debate among "
                    "policymakers, technologists, and the general public. There are growing calls for "
                    "regulations that ensure AI systems are transparent, fair, and accountable.\n\n"
                    "As AI continues to evolve, its impact on society will only deepen. The challenge lies in "
                    "harnessing its potential for good while mitigating the risks it poses to privacy, "
                    "employment, and social equity."
                ),
            },
        }
        t = topic.lower().strip()
        if t in templates:
            return templates[t]
        default = templates["travel"]
        default["title"] = f"{topic.title()} — A {difficulty} Level Reading Passage"
        default["passage"] = default["passage"].replace("sustainable tourism", topic.lower())
        return default

    @staticmethod
    def _fallback_writing_instructions(body):
        topic = body.get("topic", "general")
        difficulty = body.get("difficulty", "B2")
        task_type = body.get("task_type", "essay")
        templates = {
            "essay": (
                f"Write an essay of at least 250 words on the following topic:\n\n"
                f"\"{topic.title()}\"\n\n"
                "You should:\n"
                "• present a clear position on the issue\n"
                "• support your arguments with relevant examples\n"
                "• organise your ideas logically with clear paragraphing\n"
                "• use a range of vocabulary and grammatical structures\n\n"
                "Write at least 250 words."
            ),
            "report": (
                f"The chart/graph below shows information about {topic.lower()}.\n\n"
                "Summarise the information by selecting and reporting the main features, "
                "and make comparisons where relevant.\n\n"
                "Write at least 150 words."
            ),
            "letter": (
                f"You recently had an experience related to {topic.lower()}. "
                "Write a letter to a friend describing what happened.\n\n"
                "In your letter:\n"
                "• explain the situation\n"
                "• describe how you felt\n"
                "• suggest what your friend should do in a similar situation\n\n"
                "Write at least 150 words. You do NOT need to write any addresses."
            ),
        }
        return {"instructions": templates.get(task_type, templates["essay"])}

    @staticmethod
    def _fallback_speaking_script(body):
        part = body.get("part", "speaking_1")
        topics = body.get("topics", [])
        topic_str = ", ".join(t for t in topics if t) or "general conversation"

        scripts = {
            "speaking_1": (
                f"Part 1 — Introduction and Interview\n\n"
                f"Examiner: Good morning/afternoon. My name is [Examiner]. "
                f"Can you tell me your full name, please?\n\n"
                f"Now I'd like to ask you some questions about {topic_str}.\n\n"
                f"1. Can you tell me about your experience with {topic_str}?\n"
                f"2. How important is {topic_str} in your daily life?\n"
                f"3. Has your interest in {topic_str} changed over the years?\n"
                f"4. What do most people in your country think about {topic_str}?\n"
            ),
            "speaking_2": (
                f"Part 2 — Individual Long Turn\n\n"
                f"Examiner: Now I'm going to give you a topic, and I'd like you to talk "
                f"about it for one to two minutes. You have one minute to prepare.\n\n"
                f"Describe a time when you experienced something related to {topic_str}.\n\n"
                f"You should say:\n"
                f"• what happened\n"
                f"• when and where it happened\n"
                f"• who was involved\n"
                f"and explain how you felt about it.\n"
            ),
            "speaking_3": (
                f"Part 3 — Two-way Discussion\n\n"
                f"Examiner: We've been talking about {topic_str}, and now I'd like to "
                f"discuss some broader questions related to this topic.\n\n"
                f"1. How has {topic_str} changed in your country in recent years?\n"
                f"2. Do you think {topic_str} will be more or less important in the future? Why?\n"
                f"3. What are the advantages and disadvantages of {topic_str}?\n"
                f"4. How might governments address challenges related to {topic_str}?\n"
            ),
        }
        return {"script": scripts.get(part, scripts["speaking_1"])}

    @staticmethod
    def _fallback_listening_context(body):
        topic = body.get("topic", "everyday life")
        section_type = body.get("section_type", "social_conversation")

        transcripts = {
            "social_conversation": (
                f"[A conversation between two friends about {topic}]\n\n"
                "Speaker A: Hi! I haven't seen you in ages. How have you been?\n\n"
                "Speaker B: I've been great, thanks. Actually, I've been quite busy lately "
                f"because I've been working on something related to {topic}.\n\n"
                "Speaker A: Oh really? That sounds interesting. Tell me more about it.\n\n"
                f"Speaker B: Well, it started about three months ago when I decided to "
                f"explore {topic} more seriously. I joined a local group and we meet every "
                f"Tuesday evening to discuss different aspects of it.\n\n"
                "Speaker A: That sounds fantastic. Have you learned a lot?\n\n"
                "Speaker B: Absolutely. I've discovered that there's much more to it than "
                "I originally thought. For instance, did you know that most experts "
                "recommend starting with the basics before moving to advanced topics?\n\n"
                "Speaker A: I didn't know that. Maybe I should join your group too.\n\n"
                "Speaker B: You'd be very welcome! The next meeting is this Tuesday at "
                "seven o'clock in the community centre on Park Road."
            ),
            "academic_lecture": (
                f"[An academic lecture about {topic}]\n\n"
                f"Professor: Good morning, everyone. Today we'll be discussing {topic} "
                "and its significance in the modern world.\n\n"
                f"As you may already know, research into {topic} has expanded significantly "
                "over the past decade. Recent studies have shown that understanding this "
                "area can have far-reaching implications for both theory and practice.\n\n"
                "Let me begin by outlining the three main approaches that researchers "
                "have taken. The first approach focuses on quantitative analysis, "
                "using large datasets to identify patterns. The second emphasises "
                "qualitative methods, drawing on interviews and case studies. The third, "
                "and perhaps most promising, combines both methodologies.\n\n"
                "Now, I'd like to draw your attention to a landmark study published "
                "in 2023 by Dr. Chen and her colleagues. Their findings suggested that "
                "a combined approach yielded results that were 40% more reliable than "
                "either method used in isolation."
            ),
        }
        return {"context": transcripts.get(section_type, transcripts["social_conversation"])}

    @staticmethod
    def _fallback_exercises(module, body):
        exercise_types = body.get("exercise_types", ["mcq"])
        type_counts = body.get("type_counts", {})
        default_count = body.get("count_per_type", 5)
        difficulty = body.get("difficulty", "B2")
        questions = []

        q_templates = {
            "mcq": lambda i: {
                "type": "mcq",
                "instructions": "Choose the correct answer for each question.",
                "prompt": f"According to the passage, what is the main idea discussed in paragraph {i + 1}?",
                "options": [
                    "The historical background of the topic",
                    "The current challenges being faced",
                    "Future predictions and recommendations",
                    "A comparison of different approaches",
                ],
                "correct_answer": "The current challenges being faced",
                "explanation": "The paragraph primarily discusses the challenges faced in this area.",
                "marks": 1,
            },
            "true_false": lambda i: {
                "type": "true_false",
                "instructions": "Do the following statements agree with the information given in the passage? Write TRUE, FALSE, or NOT GIVEN.",
                "prompt": [
                    "The author supports the idea that the topic will become more important.",
                    "Research in this area began more than fifty years ago.",
                    "All experts agree on the best approach to address this issue.",
                    "The text mentions several countries where changes have occurred.",
                    "The writer believes that current measures are sufficient.",
                ][i % 5],
                "options": ["TRUE", "FALSE", "NOT GIVEN"],
                "correct_answer": ["TRUE", "FALSE", "NOT GIVEN", "TRUE", "FALSE"][i % 5],
                "explanation": "Based on the information provided in the passage.",
                "marks": 1,
            },
            "fill_blanks": lambda i: {
                "type": "fill_blanks",
                "instructions": "Complete the sentences below. Choose NO MORE THAN TWO WORDS from the passage for each answer.",
                "prompt": [
                    "The main factor contributing to changes in this area is ___.",
                    "Experts recommend that people should first focus on ___.",
                    "The study found that combined methods were ___ more effective.",
                    "Local communities have responded by implementing ___.",
                    "The primary concern raised by critics is the issue of ___.",
                ][i % 5],
                "options": [],
                "correct_answer": [
                    "growing awareness", "basic principles", "significantly",
                    "new measures", "accessibility",
                ][i % 5],
                "explanation": "This answer can be found in the relevant paragraph of the passage.",
                "marks": 1,
            },
            "matching_headings": lambda i: {
                "type": "matching_headings",
                "instructions": "Choose the correct heading for each paragraph from the list below.",
                "prompt": f"Paragraph {i + 1}",
                "options": [
                    "A. An overview of the current situation",
                    "B. Historical development",
                    "C. Future challenges and opportunities",
                    "D. Government responses",
                    "E. Expert opinions and analysis",
                ],
                "correct_answer": ["A", "B", "C", "D", "E"][i % 5],
                "explanation": f"Paragraph {i + 1} primarily deals with this topic.",
                "marks": 1,
            },
            "paragraph_match": lambda i: {
                "type": "paragraph_match",
                "instructions": "Which paragraph contains the following information?",
                "prompt": [
                    "a reference to research findings",
                    "a mention of financial concerns",
                    "an example from a specific country",
                    "a description of community initiatives",
                    "a prediction about the future",
                ][i % 5],
                "options": ["A", "B", "C", "D", "E"],
                "correct_answer": ["C", "D", "B", "A", "E"][i % 5],
                "explanation": "This information can be found in the specified paragraph.",
                "marks": 1,
            },
        }

        for et in (exercise_types or ["mcq"]):
            count = int(type_counts.get(et, default_count))
            gen_fn = q_templates.get(et, q_templates["mcq"])
            for i in range(count):
                q = gen_fn(i)
                q["difficulty"] = difficulty
                questions.append(q)

        return {"questions": questions}

    @staticmethod
    def _fallback_questions(module, body):
        difficulty = body.get("difficulty", "B2")
        count = int(body.get("count") or body.get("question_count") or 5)
        questions = []
        for i in range(count):
            questions.append({
                "type": "mcq",
                "prompt": f"Sample {module} question {i + 1} at {difficulty} level. "
                          "Which of the following best describes the main concept?",
                "options": ["Option A", "Option B", "Option C", "Option D"],
                "correct_answer": "Option A",
                "explanation": f"This is a sample question for the {module} module.",
                "difficulty": difficulty,
                "marks": 1,
            })
        return {"questions": questions}

    # ── POST /api/exam/generation/submit — create exam from generation page ──
    @http.route("/api/exam/generation/submit", type="http", auth="public", methods=["POST"], csrf=False)
    def generation_submit(self, **kw):
        from odoo.addons.encoach_api.controllers.base import validate_token
        user = validate_token()
        if not user:
            return _json_response({"error": "Authentication required"}, 401)
        request.update_env(user=user.id)
        body = _get_json()
        try:
            title = body.get("title", "").strip()
            if not title:
                return _json_response({"error": "title is required"}, 400)

            label = body.get("label", "")
            modules = body.get("modules", {})
            skip_approval = body.get("skip_approval", False)
            exam_mode = body.get("exam_mode", "official")
            structure_id = body.get("structure_id")

            first_mod = next(iter(modules.values()), {}) if modules else {}
            entity_val = first_mod.get("entity", "none")
            entity_id = int(entity_val) if entity_val and entity_val != "none" else False
            rubric_raw = first_mod.get("rubricId", "")
            rubric_id = False
            if rubric_raw and rubric_raw.startswith("rubric-"):
                try:
                    rubric_id = int(rubric_raw.split("-", 1)[1])
                except (ValueError, IndexError):
                    pass
            workflow_val = first_mod.get("approvalWorkflow", "none")
            workflow_id = int(workflow_val) if workflow_val and workflow_val != "none" else 0

            template_id = False
            try:
                Template = request.env["encoach.exam.template"]
                template = Template.sudo().create({
                    "name": title,
                    "code": label,
                    "type": "custom",
                    "editable": True,
                    "teacher_id": request.env.user.id,
                    "results_release_mode": "auto",
                })
                template_id = template.id
            except KeyError:
                pass

            try:
                Exam = request.env["encoach.exam.custom"]
            except KeyError:
                return _json_response({"error": "encoach.exam.custom model not available"}, 500)

            exam_vals = {
                "title": title,
                "label": label,
                "exam_mode": exam_mode,
                "teacher_id": request.env.user.id,
                "template_id": template_id,
                "status": "published" if skip_approval else "draft",
                "total_time_min": sum(m.get("timer", 0) for m in modules.values()),
                "total_marks": sum(float(m.get("totalMarks", 0)) for m in modules.values()),
                "randomize_questions": any(m.get("shuffling", False) for m in modules.values()),
                "grading_system": first_mod.get("gradingSystem", "ielts"),
                "access_type": first_mod.get("accessType", "private"),
                "approval_workflow_id": workflow_id,
            }
            if entity_id:
                exam_vals["entity_id"] = entity_id
            if rubric_id:
                exam_vals["rubric_id"] = rubric_id
            if structure_id:
                exam_vals["structure_id"] = int(structure_id)

            exam = Exam.sudo().create(exam_vals)

            Section = request.env["encoach.exam.custom.section"].sudo()
            Question = request.env["encoach.question"].sudo()

            CEFR_TO_DIFFICULTY = {
                "A1": "easy", "A2": "easy",
                "B1": "medium", "B2": "medium",
                "C1": "hard", "C2": "hard",
            }
            QUESTION_TYPE_MAP = {
                "mcq": "mcq", "true_false": "tfng", "fill_blanks": "gap_fill",
                "matching_headings": "heading_matching", "paragraph_match": "matching",
                "short_answer": "short_answer", "summary_completion": "summary_completion",
                "multiple_choice": "mcq", "sentence_completion": "gap_fill",
                "matching_information": "matching", "note_completion": "note_completion",
                "form_completion": "form_completion", "map_labelling": "map_labelling",
            }

            seq = 10
            total_questions = 0
            for mod_key, mod_data in modules.items():
                difficulty_list = mod_data.get("difficulty", ["B2"])
                cefr_level = difficulty_list[0] if isinstance(difficulty_list, list) and difficulty_list else "B2"
                q_difficulty = CEFR_TO_DIFFICULTY.get(cefr_level, "medium")

                section = Section.create({
                    "exam_id": exam.id,
                    "title": mod_key.capitalize(),
                    "skill": mod_key,
                    "difficulty": cefr_level,
                    "time_limit_min": mod_data.get("timer", 0),
                    "total_marks": float(mod_data.get("totalMarks", 0)),
                    "scoring_method": "rubric" if mod_key in ("writing", "speaking") else "auto",
                    "sequence": seq,
                    "content_json": json.dumps({
                        k: mod_data[k] for k in ("passages", "sections", "tasks", "parts")
                        if k in mod_data and mod_data[k]
                    }),
                })
                seq += 10

                question_ids = []

                def _q_difficulty_for(ex):
                    # Honor per-question CEFR if the AI emitted one (per-type difficulty).
                    ex_cefr = (ex.get("cefr_level") or "").strip().upper()
                    return CEFR_TO_DIFFICULTY.get(ex_cefr, q_difficulty) if ex_cefr else q_difficulty

                passages = mod_data.get("passages") or []
                for p_idx, passage in enumerate(passages):
                    if passage.get("text"):
                        section.sudo().write({"passage_text": passage["text"]})
                    for ex in (passage.get("exercises") or []):
                        q_type = QUESTION_TYPE_MAP.get(ex.get("type", "mcq"), "mcq")
                        opts = ex.get("options", [])
                        q = Question.create({
                            "skill": mod_key if mod_key in ("reading", "listening", "writing", "speaking", "grammar", "vocabulary", "math", "it") else "reading",
                            "source_type": "passage",
                            "question_type": q_type,
                            "stem": ex.get("prompt", "") or ex.get("instructions", ""),
                            "options": json.dumps(opts) if opts else "[]",
                            "correct_answer": ex.get("correct_answer", "") or "",
                            "marks": float(ex.get("marks", 1)),
                            "difficulty": _q_difficulty_for(ex),
                            "status": "active",
                            "ai_generated": True,
                        })
                        question_ids.append(q.id)

                sections_data = mod_data.get("sections") or []
                for s_data in sections_data:
                    if s_data.get("context"):
                        section.sudo().write({"passage_text": s_data["context"]})
                    for ex in (s_data.get("exercises") or []):
                        q_type = QUESTION_TYPE_MAP.get(ex.get("type", "mcq"), "mcq")
                        opts = ex.get("options", [])
                        q = Question.create({
                            "skill": "listening",
                            "source_type": "audio",
                            "question_type": q_type,
                            "stem": ex.get("prompt", "") or ex.get("instructions", ""),
                            "options": json.dumps(opts) if opts else "[]",
                            "correct_answer": ex.get("correct_answer", "") or "",
                            "marks": float(ex.get("marks", 1)),
                            "difficulty": _q_difficulty_for(ex),
                            "status": "active",
                            "ai_generated": True,
                        })
                        question_ids.append(q.id)

                tasks = mod_data.get("tasks") or []
                for t_idx, task in enumerate(tasks):
                    q = Question.create({
                        "skill": "writing",
                        "source_type": "writing_prompt",
                        "question_type": "short_answer",
                        "stem": task.get("instructions", f"Writing Task {t_idx + 1}"),
                        "options": "[]",
                        "correct_answer": "",
                        "marks": float(task.get("marks", 0)),
                        "difficulty": q_difficulty,
                        "status": "active",
                        "ai_generated": True,
                    })
                    question_ids.append(q.id)

                parts = mod_data.get("parts") or []
                for p_idx, part in enumerate(parts):
                    q = Question.create({
                        "skill": "speaking",
                        "source_type": "speaking_card",
                        "question_type": "short_answer",
                        "stem": part.get("script", f"Speaking Part {p_idx + 1}"),
                        "options": "[]",
                        "correct_answer": "",
                        "marks": float(part.get("marks", 0)),
                        "difficulty": q_difficulty,
                        "status": "active",
                        "ai_generated": True,
                    })
                    question_ids.append(q.id)

                if question_ids:
                    section.sudo().write({
                        "question_ids": [(6, 0, question_ids)],
                        "question_count": len(question_ids),
                    })
                    total_questions += len(question_ids)

            return _json_response({
                "exam_id": exam.id,
                "status": exam.status,
                "template_id": template_id,
                "total_questions": total_questions,
            }, 201)
        except Exception as e:
            _logger.exception("generation submit failed")
            return _json_response({"error": str(e)}, 500)

    # ── POST /api/ai/batch-optimize/apply — persist batch optimization ──
    @http.route("/api/ai/batch-optimize/apply", type="http", auth="public", methods=["POST"], csrf=False)
    def ai_batch_optimize_apply(self, **kw):
        body = _get_json()
        optimized = body.get("optimized", [])
        batch_id = body.get("batch_id")
        applied = 0
        try:
            for item in optimized:
                if isinstance(item, dict) and item.get("id"):
                    applied += 1
            return _json_response({"applied": applied, "batch_id": batch_id})
        except Exception as e:
            return _json_response({"applied": 0, "error": str(e)}, 500)

    # ── POST /api/exam/<module>/generate/save — save generated exam items ──
    @http.route("/api/exam/<string:module>/generate/save", type="http", auth="public", methods=["POST"], csrf=False)
    def exam_generate_save(self, module, **kw):
        from odoo.addons.encoach_api.controllers.base import validate_token
        user = validate_token()
        if not user:
            return _json_response({"error": "Authentication required"}, 401)
        request.update_env(user=user.id)
        body = _get_json()
        questions = body.get("questions", [])
        saved = 0
        try:
            try:
                Question = request.env["encoach.question"].sudo()
                for q in questions:
                    if isinstance(q, dict):
                        q_type = q.get("type", "mcq").lower().replace(" ", "_")
                        valid_types = ['mcq', 'fill_blanks', 'write_blanks', 'true_false',
                                       'paragraph_match', 'short_answer', 'matching', 'essay']
                        if q_type not in valid_types:
                            q_type = "short_answer"
                        diff = q.get("difficulty", "medium").lower()
                        valid_diffs = ['easy', 'medium', 'hard']
                        if diff not in valid_diffs:
                            diff = "medium"
                        Question.create({
                            "name": q.get("prompt", q.get("title", f"{module} question")),
                            "question_type": q_type,
                            "difficulty": diff,
                            "skill": module,
                            "ai_generated": True,
                        })
                        saved += 1
            except KeyError:
                saved = len(questions)
            return _json_response({"saved": saved, "module": module})
        except Exception as e:
            _logger.exception("exam save failed")
            return _json_response({"saved": 0, "error": str(e)}, 500)

    # ── POST /api/workbench/suggest-materials — AI material suggestions ──
    @http.route("/api/workbench/suggest-materials", type="http", auth="public", methods=["POST"], csrf=False)
    def workbench_suggest_materials(self, **kw):
        body = _get_json()
        try:
            from odoo.addons.encoach_ai.services.openai_service import OpenAIService
            ai = OpenAIService(request.env)
            messages = [
                {"role": "system", "content": (
                    "You are an educational materials expert. Suggest learning materials "
                    "for the given topic and level. Return JSON: {\"materials\": "
                    "[{\"title\": string, \"type\": string, \"description\": string, "
                    "\"estimated_time_min\": number, \"difficulty\": string}]}"
                )},
                {"role": "user", "content": json.dumps(body)},
            ]
            return _json_response(ai.chat_json(messages, model=ai.fast_model, action="suggest_materials"))
        except Exception as e:
            return _json_response({"materials": [], "error": str(e)})

    # ── Topic content generation — adaptive ──
    @http.route("/api/topics/<int:topic_id>/generate-content", type="http", auth="public", methods=["POST"], csrf=False)
    def topic_generate_content(self, topic_id, **kw):
        body = _get_json()
        try:
            from odoo.addons.encoach_ai.services.openai_service import OpenAIService
            ai = OpenAIService(request.env)
            result = ai.generate_content(
                body.get("content_type", "explanation"),
                {"topic_id": topic_id, **body},
                cefr_level=body.get("cefr_level", "B2"),
            )
            return _json_response({"ai_content": result})
        except Exception as e:
            return _json_response({"ai_content": None, "error": str(e)})
