"""REST endpoints for AI course-plan generation and browsing.

All endpoints sit under ``/api/ai/course-plan`` so they don't collide
with the existing ``/api/ai-course/...`` English / IELTS generation
endpoints. Every route is JWT-guarded via the shared ``@jwt_required``
decorator and returns JSON.
"""

import base64
import json
import logging

from odoo import http
from odoo.http import request
from odoo.addons.encoach_api.controllers.base import (
    jwt_required,
    validate_token,
    _json_response,
    _error_response,
    _get_json_body,
    _paginate,
)
from odoo.addons.encoach_ai_course.services.course_plan_pipeline import (
    CoursePlanPipeline,
)
from odoo.addons.encoach_ai_course.services.deliverables import (
    compute_deliverables,
)
from odoo.addons.encoach_ai_course.services.media_service import MediaService
from odoo.addons.encoach_ai_course.services.exercise_extractor import (
    ExerciseExtractor,
)

_logger = logging.getLogger(__name__)


def _request_language():
    """Return the UI language sent by the frontend as a short ISO code."""
    try:
        raw = (
            request.httprequest.headers.get('X-UI-Language')
            or request.httprequest.headers.get('Accept-Language')
            or 'en'
        )
    except Exception:
        raw = 'en'
    return str(raw).split(',')[0].split(';')[0].split('-')[0].strip().lower() or 'en'


def _entity_scope():
    """Return (entity_ids, is_superadmin) for current JWT user.

    Mirrors ``encoach_lms_api.controllers.lms_core._entity_scope``: any user
    linked to one or more entities is entity-scoped, even when they carry
    Odoo settings groups.
    """
    user = request.env.user.sudo()
    entity_ids = user.entity_ids.ids if hasattr(user, 'entity_ids') else []
    if entity_ids:
        return entity_ids, False
    is_super = bool(
        user.id == 1 or user.has_group('base.group_system')
    )
    return entity_ids, is_super


def _default_entity_id_from_scope():
    entity_ids, is_super = _entity_scope()
    if entity_ids:
        return entity_ids[0]
    if is_super:
        return False
    raise PermissionError('User is not linked to any entity')


def _ensure_entity_access(entity_id):
    if not entity_id:
        raise PermissionError('entity_id is required')
    entity_ids, is_super = _entity_scope()
    if is_super:
        return int(entity_id)
    if not entity_ids:
        raise PermissionError('User is not linked to any entity')
    if int(entity_id) not in entity_ids:
        raise PermissionError('Entity access denied')
    return int(entity_id)


class CoursePlanController(http.Controller):
    def _plan_domain(self, extra=None):
        domain = list(extra or [])
        entity_ids, is_super = _entity_scope()
        if is_super:
            return domain
        if not entity_ids:
            return domain + [('id', '=', 0)]
        return domain + [('entity_id', 'in', entity_ids)]

    def _assert_plan_access(self, plan):
        if not plan or not plan.exists():
            raise ValueError('Plan not found')
        entity_ids, is_super = _entity_scope()
        if is_super:
            return
        if not plan.entity_id or plan.entity_id.id not in entity_ids:
            raise PermissionError('Entity access denied')

    def _get_plan_scoped(self, plan_id):
        plan = request.env['encoach.course.plan'].sudo().browse(int(plan_id))
        self._assert_plan_access(plan)
        return plan

    def _assert_material_access(self, material):
        if not material or not material.exists():
            raise ValueError('Material not found')
        self._assert_plan_access(material.plan_id)

    # ------------------------------------------------------------------
    # POST /api/ai/course-plan
    # ------------------------------------------------------------------
    @http.route('/api/ai/course-plan', type='http', auth='none',
                methods=['POST'], csrf=False)
    @jwt_required
    def generate_plan(self, **kw):
        try:
            body = _get_json_body()
            if not (body.get('title') or '').strip():
                return _error_response('title is required', 400)
            if body.get('entity_id'):
                entity_id = _ensure_entity_access(int(body['entity_id']))
            else:
                entity_id = _default_entity_id_from_scope()

            pipeline = CoursePlanPipeline(
                request.env, language=_request_language(),
            )
            plan = pipeline.generate_plan(body)
            if entity_id:
                plan.sudo().write({'entity_id': entity_id})
            return _json_response({'data': plan.to_api_dict(include_weeks=True)})
        except Exception as exc:
            _logger.exception('course-plan.generate failed')
            return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)

    # ------------------------------------------------------------------
    # GET /api/ai/course-plan
    # ------------------------------------------------------------------
    @http.route('/api/ai/course-plan', type='http', auth='none',
                methods=['GET'], csrf=False)
    @jwt_required
    def list_plans(self, **kw):
        try:
            params = request.httprequest.args
            domain = self._plan_domain([])
            search = (params.get('search') or '').strip()
            if search:
                domain.append(('name', 'ilike', search))
            if params.get('entity_id'):
                domain.append(('entity_id', '=', _ensure_entity_access(int(params.get('entity_id')))))

            Plan = request.env['encoach.course.plan'].sudo()
            offset, limit, page = _paginate({
                'page': params.get('page', 0),
                'size': params.get('size', 20),
            })
            total = Plan.search_count(domain)
            records = Plan.search(
                domain, offset=offset, limit=limit,
                order='create_date desc, id desc',
            )
            return _json_response({
                'items': [r.to_api_dict(include_weeks=False) for r in records],
                'page': {'page': page, 'size': limit, 'total': total},
            })
        except Exception as exc:
            _logger.exception('course-plan.list failed')
            return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)

    # ------------------------------------------------------------------
    # GET /api/ai/course-plan/<id>
    # ------------------------------------------------------------------
    @http.route('/api/ai/course-plan/<int:plan_id>', type='http',
                auth='none', methods=['GET'], csrf=False)
    @jwt_required
    def get_plan(self, plan_id, **kw):
        try:
            plan = self._get_plan_scoped(plan_id)
            return _json_response({
                'data': plan.to_api_dict(include_weeks=True, include_materials=True),
            })
        except ValueError as exc:
            return _error_response(str(exc), 404)
        except Exception as exc:
            _logger.exception('course-plan.get failed')
            return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)

    # ------------------------------------------------------------------
    # DELETE /api/ai/course-plan/<id>
    # ------------------------------------------------------------------
    @http.route('/api/ai/course-plan/<int:plan_id>', type='http',
                auth='none', methods=['DELETE'], csrf=False)
    @jwt_required
    def delete_plan(self, plan_id, **kw):
        try:
            plan = self._get_plan_scoped(plan_id)
            plan.unlink()
            return _json_response({'success': True})
        except ValueError as exc:
            return _error_response(str(exc), 404)
        except Exception as exc:
            _logger.exception('course-plan.delete failed')
            return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)

    # ------------------------------------------------------------------
    # POST /api/ai/course-plan/<id>/weeks/<n>/materials
    # ------------------------------------------------------------------
    @http.route('/api/ai/course-plan/<int:plan_id>/weeks/<int:week_number>/materials',
                type='http', auth='none', methods=['POST'], csrf=False)
    @jwt_required
    def generate_week_materials(self, plan_id, week_number, **kw):
        try:
            self._get_plan_scoped(plan_id)
            pipeline = CoursePlanPipeline(
                request.env, language=_request_language(),
            )
            materials = pipeline.generate_week_materials(plan_id, week_number)
            return _json_response({
                'items': [m.to_api_dict() for m in materials],
                'count': len(materials),
            })
        except ValueError as exc:
            return _error_response(str(exc), 404)
        except Exception as exc:
            _logger.exception('course-plan.generate_week_materials failed')
            return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)

    # ------------------------------------------------------------------
    # POST /api/ai/course-plan/<id>/weeks/<n>/materials/v2
    # ------------------------------------------------------------------
    # RAG-grounded richer-schema generator. Requires at least one indexed
    # source on the plan; if none exists, the response is a 409 so the UI
    # can prompt the user to upload a reference book first. The pipeline
    # is otherwise identical to /materials but persists ``grounded_on_json``
    # on each created row and returns a ``rag.sources_used`` summary.
    @http.route('/api/ai/course-plan/<int:plan_id>/weeks/<int:week_number>/materials/v2',
                type='http', auth='none', methods=['POST'], csrf=False)
    @jwt_required
    def generate_week_materials_v2(self, plan_id, week_number, **kw):
        try:
            plan = self._get_plan_scoped(plan_id)
            from odoo.addons.encoach_ai_course.services.rag_context import (
                RAGContextBuilder,
            )
            builder = RAGContextBuilder(request.env)
            if not builder.has_indexed_sources(plan):
                return _error_response(
                    'No indexed reference sources for this plan. '
                    'Upload at least one PDF/DOCX before using v2.',
                    409,
                )
            pipeline = CoursePlanPipeline(
                request.env, language=_request_language(),
            )
            materials = pipeline.generate_week_materials(
                plan_id, week_number, use_rag=True,
            )
            sources_used = sorted({
                src['source_id']
                for m in materials
                for src in (m._loads(m.grounded_on_json, []) or [])
                if src.get('source_id')
            })
            return _json_response({
                'items': [m.to_api_dict() for m in materials],
                'count': len(materials),
                'rag': {
                    'enabled': True,
                    'sources_used': sources_used,
                },
            })
        except ValueError as exc:
            return _error_response(str(exc), 404)
        except Exception as exc:
            _logger.exception('course-plan.generate_week_materials_v2 failed')
            return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)

    # ------------------------------------------------------------------
    # POST /api/ai/course-plan/<id>/extract-workbooks
    # ------------------------------------------------------------------
    @http.route('/api/ai/course-plan/<int:plan_id>/extract-workbooks',
                type='http', auth='none', methods=['POST'], csrf=False)
    @jwt_required
    def extract_workbooks(self, plan_id, **kw):
        """Mine indexed sources for original exercises and persist them
        as ``interactive_workbook`` materials on the plan."""
        try:
            plan = self._get_plan_scoped(plan_id)
            body = _get_json_body() or {}
            try:
                max_batches = int(body.get('max_batches') or 0) or 8
            except (TypeError, ValueError):
                max_batches = 8
            extractor = ExerciseExtractor(
                request.env, language=_request_language(),
            )
            stats = extractor.run(plan, max_batches=max_batches)
            return _json_response({
                'data': stats,
                'plan_id': plan.id,
            })
        except ValueError as exc:
            return _error_response(str(exc), 404)
        except Exception as exc:
            _logger.exception('course-plan.extract_workbooks failed')
            return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)

    # ------------------------------------------------------------------
    # GET /api/ai/course-plan/material/<id>/grounding
    # ------------------------------------------------------------------
    @http.route('/api/ai/course-plan/material/<int:material_id>/grounding',
                type='http', auth='none', methods=['GET'], csrf=False)
    @jwt_required
    def get_material_grounding(self, material_id, **kw):
        """Return the source citations + extracted-from provenance for a material."""
        try:
            material = self._resolve_material(material_id)
            if not material:
                return _error_response('Material not found', 404)
            grounded = material._loads(material.grounded_on_json, [])
            extracted = material._loads(material.extracted_from_json, None)
            sources = []
            ids = [
                int(s.get('source_id')) for s in (grounded or [])
                if s.get('source_id')
            ]
            if extracted and extracted.get('source_id'):
                ids.append(int(extracted['source_id']))
            if ids:
                Source = request.env['encoach.course.plan.source'].sudo()
                rows = Source.browse(list(set(ids)))
                sources = [r.to_api_dict() for r in rows if r.exists()]
            return _json_response({
                'material_id': material.id,
                'grounded_on': grounded if isinstance(grounded, list) else [],
                'extracted_from': extracted if isinstance(extracted, dict) else None,
                'sources': sources,
            })
        except Exception as exc:
            _logger.exception('course-plan.get_material_grounding failed')
            code = 404 if isinstance(exc, ValueError) else (
                403 if isinstance(exc, PermissionError) else 500
            )
            return _error_response(str(exc), code)

    # ------------------------------------------------------------------
    # GET /api/ai/course-plan/<id>/weeks/<n>/materials
    # ------------------------------------------------------------------
    @http.route('/api/ai/course-plan/<int:plan_id>/weeks/<int:week_number>/materials',
                type='http', auth='none', methods=['GET'], csrf=False)
    @jwt_required
    def list_week_materials(self, plan_id, week_number, **kw):
        try:
            self._get_plan_scoped(plan_id)
            week = request.env['encoach.course.plan.week'].sudo().search([
                ('plan_id', '=', int(plan_id)),
                ('week_number', '=', int(week_number)),
            ], limit=1)
            if not week:
                return _error_response('Week not found', 404)
            return _json_response({
                'items': [m.to_api_dict() for m in week.material_ids],
                'count': len(week.material_ids),
            })
        except Exception as exc:
            _logger.exception('course-plan.list_week_materials failed')
            return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)

    # ==================================================================
    # PHASE A — Reference sources (RAG grounding)
    # ==================================================================

    @http.route('/api/ai/course-plan/<int:plan_id>/sources',
                type='http', auth='none', methods=['GET'], csrf=False)
    @jwt_required
    def list_sources(self, plan_id, **kw):
        try:
            plan = self._get_plan_scoped(plan_id)
            return _json_response({
                'items': [s.to_api_dict() for s in plan.source_ids],
                'count': len(plan.source_ids),
            })
        except ValueError as exc:
            return _error_response(str(exc), 404)
        except Exception as exc:
            _logger.exception('course-plan.list_sources failed')
            return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)

    @http.route('/api/ai/course-plan/<int:plan_id>/sources',
                type='http', auth='none', methods=['POST'], csrf=False)
    @jwt_required
    def create_source(self, plan_id, **kw):
        try:
            plan = self._get_plan_scoped(plan_id)

            ct = request.httprequest.content_type or ''
            files = request.httprequest.files
            uploaded = files.get('file')
            if 'application/json' in ct:
                body = _get_json_body() or {}
            else:
                body = {**kw}

            kind = (body.get('kind') or '').strip() or (
                'file' if uploaded else
                ('url' if (body.get('url') or '').strip() else 'text')
            )
            name = (body.get('name') or '').strip()
            auto_index = body.get('auto_index')
            if isinstance(auto_index, str):
                auto_index = auto_index.lower() not in ('0', 'false', 'no', 'off')
            elif auto_index is None:
                auto_index = True
            vals = {
                'plan_id': plan.id,
                'kind': kind,
                'auto_index': bool(auto_index),
            }
            if kind == 'file':
                if not uploaded:
                    return _error_response('No file uploaded', 400)
                payload = uploaded.read()
                vals['file'] = base64.b64encode(payload)
                vals['file_name'] = uploaded.filename or 'source'
                vals['mime_type'] = uploaded.mimetype or ''
                vals['name'] = name or uploaded.filename or 'source'
            elif kind == 'url':
                url = (body.get('url') or '').strip()
                if not url:
                    return _error_response('URL is required', 400)
                vals['url'] = url
                vals['name'] = name or url
            else:
                text = (body.get('inline_text') or body.get('text') or '').strip()
                if not text:
                    return _error_response('Inline text is required', 400)
                vals['inline_text'] = text
                vals['name'] = name or 'Inline text'

            rec = request.env['encoach.course.plan.source'].sudo().create(vals)
            return _json_response({'data': rec.to_api_dict()})
        except ValueError as exc:
            return _error_response(str(exc), 404)
        except Exception as exc:
            _logger.exception('course-plan.create_source failed')
            return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)

    # ------------------------------------------------------------------
    # POST /api/ai/course-plan/<plan_id>/sources/from-resources
    # ------------------------------------------------------------------
    # Attach one or more existing library resources (``encoach.resource``,
    # the items shown under /admin/resources) as RAG sources for a plan.
    #
    # Body shape: ``{ "resource_ids": [<int>, ...] }``. We dedupe against
    # already-linked resources so re-clicking "Attach" is a no-op rather
    # than producing duplicate index entries. Each new row auto-indexes
    # via the ``encoach.course.plan.source`` create() hook.
    @http.route('/api/ai/course-plan/<int:plan_id>/sources/from-resources',
                type='http', auth='none', methods=['POST'], csrf=False)
    @jwt_required
    def attach_library_resources(self, plan_id, **kw):
        try:
            plan = self._get_plan_scoped(plan_id)

            body = _get_json_body() or {}
            raw_ids = body.get('resource_ids') or body.get('ids') or []
            if not isinstance(raw_ids, list):
                return _error_response('resource_ids must be a list', 400)
            try:
                resource_ids = [int(x) for x in raw_ids if x is not None]
            except (TypeError, ValueError):
                return _error_response('resource_ids must contain integers', 400)
            if not resource_ids:
                return _error_response('No resource ids provided', 400)

            Resource = request.env['encoach.resource'].sudo()
            Source = request.env['encoach.course.plan.source'].sudo()

            already_linked = set(
                plan.source_ids.filtered('resource_id').mapped('resource_id.id')
            )
            attached, skipped, missing = [], [], []
            for rid in resource_ids:
                if rid in already_linked:
                    skipped.append(rid)
                    continue
                res = Resource.browse(rid)
                if not res.exists():
                    missing.append(rid)
                    continue
                rec = Source.create({
                    'plan_id': plan.id,
                    'kind': 'resource',
                    'resource_id': res.id,
                    'name': res.name or f'Resource #{res.id}',
                    'file_name': res.name or '',
                    'mime_type': '',
                })
                attached.append(rec.to_api_dict())

            return _json_response({
                'attached': attached,
                'skipped_existing': skipped,
                'missing': missing,
                'count': len(attached),
            })
        except ValueError as exc:
            return _error_response(str(exc), 404)
        except Exception as exc:
            _logger.exception('course-plan.attach_library_resources failed')
            return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)

    @http.route('/api/ai/course-plan/<int:plan_id>/sources/<int:source_id>/index',
                type='http', auth='none', methods=['POST'], csrf=False)
    @jwt_required
    def reindex_source(self, plan_id, source_id, **kw):
        try:
            self._get_plan_scoped(plan_id)
            rec = request.env['encoach.course.plan.source'].sudo().browse(int(source_id))
            if not rec.exists() or rec.plan_id.id != int(plan_id):
                return _error_response('Source not found', 404)
            rec.action_index()
            return _json_response({'data': rec.to_api_dict()})
        except ValueError as exc:
            return _error_response(str(exc), 404)
        except Exception as exc:
            _logger.exception('course-plan.reindex_source failed')
            return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)

    @http.route('/api/ai/course-plan/<int:plan_id>/sources/<int:source_id>',
                type='http', auth='none', methods=['DELETE'], csrf=False)
    @jwt_required
    def delete_source(self, plan_id, source_id, **kw):
        try:
            self._get_plan_scoped(plan_id)
            rec = request.env['encoach.course.plan.source'].sudo().browse(int(source_id))
            if not rec.exists() or rec.plan_id.id != int(plan_id):
                return _error_response('Source not found', 404)
            rec.unlink()
            return _json_response({'success': True})
        except ValueError as exc:
            return _error_response(str(exc), 404)
        except Exception as exc:
            _logger.exception('course-plan.delete_source failed')
            return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)

    # ==================================================================
    # PHASE B — Deliverables preview / progress
    # ==================================================================

    @http.route('/api/ai/course-plan/<int:plan_id>/deliverables',
                type='http', auth='none', methods=['GET'], csrf=False)
    @jwt_required
    def get_deliverables(self, plan_id, **kw):
        try:
            plan = self._get_plan_scoped(plan_id)
            return _json_response(compute_deliverables(plan))
        except ValueError as exc:
            return _error_response(str(exc), 404)
        except Exception as exc:
            _logger.exception('course-plan.deliverables failed')
            return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)

    # ==================================================================
    # PHASE C — Multimedia generation per material
    # ==================================================================

    def _resolve_material(self, material_id):
        rec = request.env['encoach.course.plan.material'].sudo().browse(int(material_id))
        if not rec.exists():
            return None
        self._assert_material_access(rec)
        return rec

    @http.route('/api/ai/course-plan/material/<int:material_id>',
                type='http', auth='none', methods=['PATCH'], csrf=False)
    @jwt_required
    def update_material(self, material_id, **kw):
        """Edit generated material metadata/content without regenerating."""
        try:
            material = self._resolve_material(material_id)
            if not material:
                return _error_response('Material not found', 404)
            body = _get_json_body() or {}
            vals = {}
            if 'title' in body:
                new_title = (body.get('title') or '').strip()
                if not new_title:
                    return _error_response('title cannot be empty', 400)
                vals['title'] = new_title
            if 'summary' in body:
                vals['summary'] = (body.get('summary') or '').strip()
            if 'is_static' in body:
                vals['is_static'] = bool(body.get('is_static'))
            if 'share_date' in body:
                vals['share_date'] = body.get('share_date') or False
            if 'body' in body:
                vals['body_json'] = json.dumps(body.get('body') or {}, ensure_ascii=False)
            if 'body_text' in body:
                body_text = (body.get('body_text') or '').strip()
                vals['body_text'] = body_text
                if 'body' not in body and body_text:
                    # Keep non-technical editing simple: if only plain text is
                    # provided, mirror it into a minimal JSON structure.
                    vals['body_json'] = json.dumps({'text': body_text}, ensure_ascii=False)
            if vals:
                material.sudo().write(vals)
            return _json_response({'data': material.to_api_dict()})
        except Exception as exc:
            _logger.exception('course-plan.update_material failed')
            code = 404 if isinstance(exc, ValueError) else 403 if isinstance(exc, PermissionError) else 500
            return _error_response(str(exc), code)

    @http.route('/api/ai/course-plan/material/<int:material_id>/media/audio',
                type='http', auth='none', methods=['POST'], csrf=False)
    @jwt_required
    def gen_audio(self, material_id, **kw):
        try:
            material = self._resolve_material(material_id)
            if not material:
                return _error_response('Material not found', 404)
            body = _get_json_body() or {}
            svc = MediaService(request.env)
            media = svc.synthesize_audio(
                material,
                voice=body.get('voice'),
                language=body.get('language') or 'en-GB',
                gender=body.get('gender') or 'female',
                provider=body.get('provider') or 'auto',
            )
            return _json_response({'data': media.to_api_dict()})
        except Exception as exc:
            _logger.exception('course-plan.gen_audio failed')
            code = 404 if isinstance(exc, ValueError) else 403 if isinstance(exc, PermissionError) else 500
            return _error_response(str(exc), code)

    @http.route('/api/ai/course-plan/material/<int:material_id>/media/image',
                type='http', auth='none', methods=['POST'], csrf=False)
    @jwt_required
    def gen_image(self, material_id, **kw):
        try:
            material = self._resolve_material(material_id)
            if not material:
                return _error_response('Material not found', 404)
            body = _get_json_body() or {}
            svc = MediaService(request.env)
            media = svc.generate_image(
                material,
                custom_prompt=body.get('prompt'),
                size=body.get('size') or '1024x1024',
                style=body.get('style') or 'natural',
                quality=body.get('quality') or 'standard',
            )
            return _json_response({'data': media.to_api_dict()})
        except Exception as exc:
            _logger.exception('course-plan.gen_image failed')
            code = 404 if isinstance(exc, ValueError) else 403 if isinstance(exc, PermissionError) else 500
            return _error_response(str(exc), code)

    @http.route('/api/ai/course-plan/material/<int:material_id>/media/video',
                type='http', auth='none', methods=['POST'], csrf=False)
    @jwt_required
    def gen_video(self, material_id, **kw):
        try:
            material = self._resolve_material(material_id)
            if not material:
                return _error_response('Material not found', 404)
            svc = MediaService(request.env)
            media = svc.compose_video(material)
            return _json_response({'data': media.to_api_dict()})
        except Exception as exc:
            _logger.exception('course-plan.gen_video failed')
            code = 404 if isinstance(exc, ValueError) else 403 if isinstance(exc, PermissionError) else 500
            return _error_response(str(exc), code)

    @http.route(
        '/api/ai/course-plan/material/<int:material_id>/media/upload',
        type='http', auth='none', methods=['POST'], csrf=False,
    )
    @jwt_required
    def upload_media(self, material_id, **kw):
        """Attach an admin-supplied audio / image / video file to a material.

        Multipart form fields:

        * ``kind``  — ``audio`` | ``image`` | ``video`` (required)
        * ``file``  — the binary blob (required)
        * ``title`` — optional human label, defaults to the filename

        The persisted ``encoach.course.plan.media`` row is marked
        ``provider='manual'`` so the drawer renders it distinctly from
        AI-generated assets and admins know which clips were hand-curated.
        Hard 100 MB cap on the upload to defend the filestore against an
        accidental 4K-master-from-a-DSLR upload.
        """
        try:
            material = self._resolve_material(material_id)
            if not material:
                return _error_response('Material not found', 404)

            kind = (request.httprequest.form.get('kind') or '').strip().lower()
            if kind not in ('audio', 'image', 'video'):
                return _error_response(
                    "kind must be one of 'audio', 'image', 'video'", 400,
                )

            uploaded = request.httprequest.files.get('file')
            if not uploaded or not uploaded.filename:
                return _error_response(
                    "Multipart field 'file' is required", 400,
                )
            data = uploaded.read()
            if not data:
                return _error_response('Uploaded file is empty', 400)
            # 100 MB upper bound — well above the largest realistic
            # listening track or lesson video, well below anything that
            # could OOM the worker on base64-encode.
            if len(data) > 100 * 1024 * 1024:
                return _error_response(
                    'File too large — 100 MB maximum per upload', 413,
                )
            mime_type = (uploaded.mimetype or '').lower() or 'application/octet-stream'
            expected_prefix = {
                'audio': 'audio/',
                'image': 'image/',
                'video': 'video/',
            }[kind]
            # We don't *require* a strict mime match (browsers sometimes
            # send octet-stream for .m4a, etc.) but if a caller flags
            # ``kind=audio`` and uploads a clearly-image MIME, that's a
            # real bug worth surfacing rather than silently swallowing.
            if (mime_type
                    and not mime_type.startswith(expected_prefix)
                    and mime_type != 'application/octet-stream'):
                return _error_response(
                    f"MIME {mime_type!r} does not match kind={kind!r}", 400,
                )

            title = (
                (request.httprequest.form.get('title') or '').strip()
                or uploaded.filename
            )

            Media = request.env['encoach.course.plan.media'].sudo()
            media = Media.create({
                'plan_id': material.plan_id.id,
                'week_id': material.week_id.id if material.week_id else False,
                'material_id': material.id,
                'kind': kind,
                'provider': 'manual',
                'title': title,
                'mime_type': mime_type,
                'size_bytes': len(data),
                'status': 'generating',
            })
            attach = request.env['ir.attachment'].sudo().create({
                'name': uploaded.filename,
                'type': 'binary',
                'datas': base64.b64encode(data),
                'mimetype': mime_type,
                'res_model': 'encoach.course.plan.media',
                'res_id': media.id,
                'public': True,
            })
            media.write({
                'attachment_id': attach.id,
                'status': 'ready',
                'error': False,
            })
            return _json_response({'data': media.to_api_dict()})
        except Exception as exc:
            _logger.exception('course-plan.upload_media failed')
            code = 404 if isinstance(exc, ValueError) else 403 if isinstance(exc, PermissionError) else 500
            return _error_response(str(exc), code)

    @http.route('/api/ai/course-plan/material/<int:material_id>/media',
                type='http', auth='none', methods=['GET'], csrf=False)
    @jwt_required
    def list_material_media(self, material_id, **kw):
        try:
            material = self._resolve_material(material_id)
            if not material:
                return _error_response('Material not found', 404)
            return _json_response({
                'items': [m.to_api_dict() for m in material.media_ids],
                'count': len(material.media_ids),
            })
        except Exception as exc:
            _logger.exception('course-plan.list_material_media failed')
            code = 404 if isinstance(exc, ValueError) else 403 if isinstance(exc, PermissionError) else 500
            return _error_response(str(exc), code)

    # ------------------------------------------------------------------
    # GET /api/ai/course-plan/media/<id>/raw
    # ------------------------------------------------------------------
    # Streams the binary backing a media row. Used as the ``src`` for
    # ``<img>`` / ``<audio>`` / ``<video>`` tags AND as the target of
    # download links — the only difference is the ``?download=1`` flag.
    #
    # Accepts the JWT via the standard ``Authorization: Bearer …`` header
    # OR via ``?token=<jwt>`` / ``?access_token=<jwt>`` so plain ``<img>``
    # tags can render the asset without us blob-converting every fetch.
    @http.route('/api/ai/course-plan/media/<int:media_id>/raw',
                type='http', auth='none', methods=['GET'], csrf=False)
    def stream_media(self, media_id, **kw):
        try:
            user = validate_token(allow_query_param=True)
            if not user:
                return _error_response('Authentication required', 401)
            request.update_env(user=user.id)

            rec = request.env['encoach.course.plan.media'].sudo().browse(int(media_id))
            if not rec.exists() or not rec.attachment_id:
                return _error_response('Media not found', 404)
            self._assert_plan_access(rec.plan_id)

            attachment = rec.attachment_id
            payload = attachment.raw or b''
            if not payload and attachment.datas:
                # Older rows store the binary base64-encoded under ``datas``.
                payload = base64.b64decode(attachment.datas)
            if not payload:
                return _error_response('Media payload missing', 410)

            mimetype = attachment.mimetype or rec.mime_type or 'application/octet-stream'
            filename = attachment.name or f'media-{rec.id}'
            disposition = (
                f'attachment; filename="{filename}"'
                if request.httprequest.args.get('download')
                else f'inline; filename="{filename}"'
            )
            headers = [
                ('Content-Type', mimetype),
                ('Content-Length', str(len(payload))),
                ('Content-Disposition', disposition),
                # Aggressive caching is safe — the URL is keyed by the row id
                # and the binary is immutable once generated. Setting a 1h
                # max-age avoids re-streaming the same WAV/PNG every time the
                # admin re-opens the media drawer.
                ('Cache-Control', 'private, max-age=3600'),
            ]
            return request.make_response(payload, headers=headers)
        except Exception as exc:
            _logger.exception('course-plan.stream_media failed')
            code = 404 if isinstance(exc, ValueError) else 403 if isinstance(exc, PermissionError) else 500
            return _error_response(str(exc), code)

    @http.route('/api/ai/course-plan/media/<int:media_id>',
                type='http', auth='none', methods=['DELETE'], csrf=False)
    @jwt_required
    def delete_media(self, media_id, **kw):
        try:
            rec = request.env['encoach.course.plan.media'].sudo().browse(int(media_id))
            if not rec.exists():
                return _error_response('Media not found', 404)
            self._assert_plan_access(rec.plan_id)
            if rec.attachment_id:
                rec.attachment_id.unlink()
            rec.unlink()
            return _json_response({'success': True})
        except Exception as exc:
            _logger.exception('course-plan.delete_media failed')
            code = 404 if isinstance(exc, ValueError) else 403 if isinstance(exc, PermissionError) else 500
            return _error_response(str(exc), code)

    @http.route('/api/ai/course-plan/<int:plan_id>/weeks/<int:week_number>/media',
                type='http', auth='none', methods=['POST'], csrf=False)
    @jwt_required
    def gen_week_media(self, plan_id, week_number, **kw):
        """Generate every applicable modality for every material in a week.

        Body shape: ``{ kinds: ['audio', 'image', 'video'] }``.
        Default is ``['audio', 'image']`` — video is opt-in because it
        depends on ffmpeg + the audio + image steps and is slower.
        """
        try:
            plan = self._get_plan_scoped(plan_id)
            week = plan.week_ids.filtered(
                lambda w: w.week_number == int(week_number),
            )
            if not week:
                return _error_response('Week not found', 404)
            week = week[0]
            body = _get_json_body() or {}
            kinds = body.get('kinds') or ['audio', 'image']
            kinds = [str(k).lower() for k in kinds]

            svc = MediaService(request.env)
            results = []
            for material in week.material_ids:
                if 'audio' in kinds and material.material_type in (
                    'listening_script', 'speaking_prompt',
                ):
                    results.append(svc.synthesize_audio(material).to_api_dict())
                if 'image' in kinds and material.material_type in (
                    'reading_text', 'listening_script', 'vocabulary_list',
                ):
                    try:
                        results.append(svc.generate_image(material).to_api_dict())
                    except Exception as exc:
                        results.append({'error': str(exc), 'material_id': material.id})
                if 'video' in kinds and material.material_type == 'listening_script':
                    results.append(svc.compose_video(material).to_api_dict())
            return _json_response({'items': results, 'count': len(results)})
        except Exception as exc:
            _logger.exception('course-plan.gen_week_media failed')
            code = 404 if isinstance(exc, ValueError) else 403 if isinstance(exc, PermissionError) else 500
            return _error_response(str(exc), code)

    # ==================================================================
    # PHASE D — Plan assignments
    # ==================================================================

    @http.route('/api/ai/course-plan/<int:plan_id>/assignments',
                type='http', auth='none', methods=['GET'], csrf=False)
    @jwt_required
    def list_assignments(self, plan_id, **kw):
        try:
            plan = self._get_plan_scoped(plan_id)
            return _json_response({
                'items': [a.to_api_dict() for a in plan.assignment_ids],
                'count': len(plan.assignment_ids),
            })
        except ValueError as exc:
            return _error_response(str(exc), 404)
        except Exception as exc:
            _logger.exception('course-plan.list_assignments failed')
            return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)

    @http.route('/api/ai/course-plan/<int:plan_id>/assignments',
                type='http', auth='none', methods=['POST'], csrf=False)
    @jwt_required
    def create_assignment(self, plan_id, **kw):
        try:
            plan = self._get_plan_scoped(plan_id)
            body = _get_json_body() or {}
            mode = (body.get('mode') or 'batch').strip()
            vals = {
                'plan_id': plan.id,
                'mode': mode,
                'message': (body.get('message') or '').strip(),
                'due_date': body.get('due_date') or False,
                'state': 'active',
            }
            if mode == 'batch':
                if not body.get('batch_id'):
                    return _error_response('batch_id is required', 400)
                batch_id = int(body['batch_id'])
                batch = request.env['op.batch'].sudo().browse(batch_id)
                if not batch.exists():
                    return _error_response('Batch not found', 404)
                if plan.entity_id and batch.entity_id and plan.entity_id.id != batch.entity_id.id:
                    return _error_response('Batch entity does not match plan entity', 400)
                vals['batch_id'] = batch_id
            elif mode == 'students':
                ids = body.get('student_user_ids') or []
                if not isinstance(ids, list) or not ids:
                    return _error_response('student_user_ids is required', 400)
                vals['student_user_ids'] = [(6, 0, [int(i) for i in ids])]
            elif mode == 'entities':
                ids = body.get('entity_ids') or []
                if not isinstance(ids, list) or not ids:
                    return _error_response('entity_ids is required', 400)
                checked = [_ensure_entity_access(int(i)) for i in ids]
                vals['entity_ids'] = [(6, 0, checked)]
            else:
                return _error_response('Invalid mode', 400)
            rec = request.env['encoach.course.plan.assignment'].sudo().create(vals)
            return _json_response({'data': rec.to_api_dict()})
        except ValueError as exc:
            return _error_response(str(exc), 404)
        except Exception as exc:
            _logger.exception('course-plan.create_assignment failed')
            return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)

    @http.route('/api/ai/course-plan/<int:plan_id>/assignments/<int:assignment_id>',
                type='http', auth='none', methods=['DELETE'], csrf=False)
    @jwt_required
    def delete_assignment(self, plan_id, assignment_id, **kw):
        try:
            self._get_plan_scoped(plan_id)
            rec = request.env['encoach.course.plan.assignment'].sudo().browse(
                int(assignment_id),
            )
            if not rec.exists() or rec.plan_id.id != int(plan_id):
                return _error_response('Assignment not found', 404)
            rec.unlink()
            return _json_response({'success': True})
        except ValueError as exc:
            return _error_response(str(exc), 404)
        except Exception as exc:
            _logger.exception('course-plan.delete_assignment failed')
            return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)

    # ==================================================================
    # PHASE E — Student-side endpoints
    # ==================================================================

    @http.route('/api/student/course-plans',
                type='http', auth='none', methods=['GET'], csrf=False)
    @jwt_required
    def student_list_plans(self, **kw):
        """Return the course plans assigned to the authenticated user.

        Visibility rules (OR):
          1. There exists an assignment with ``mode='students'`` listing
             the current user.
          2. There exists an assignment with ``mode='batch'`` whose
             batch contains an ``op.student.course`` whose student's
             ``user_id`` matches the current user.
        """
        try:
            user = request.env.user
            Assignment = request.env['encoach.course.plan.assignment'].sudo()
            assignments = Assignment.search([('state', '=', 'active')])
            visible = []
            for a in assignments:
                if a.mode == 'students' and user.id in a.student_user_ids.ids:
                    visible.append(a)
                    continue
                if a.mode == 'batch' and user.id in a.expand_user_ids():
                    visible.append(a)
                    continue
                if a.mode == 'entities' and user.id in a.expand_user_ids():
                    visible.append(a)

            seen = set()
            out = []
            for a in visible:
                if a.plan_id.id in seen:
                    continue
                seen.add(a.plan_id.id)
                plan_data = a.plan_id.to_api_dict(
                    include_weeks=False, include_materials=False,
                )
                plan_data['assignment'] = a.to_api_dict()
                out.append(plan_data)
            return _json_response({'items': out, 'count': len(out)})
        except Exception as exc:
            _logger.exception('course-plan.student_list_plans failed')
            return _error_response(str(exc), 500)

    # ------------------------------------------------------------------
    # POST /api/student/course-plans/<plan_id>/materials/<material_id>/attempts
    # ------------------------------------------------------------------
    @http.route(
        '/api/student/course-plans/<int:plan_id>/materials/<int:material_id>/attempts',
        type='http', auth='none', methods=['POST'], csrf=False,
    )
    @jwt_required
    def student_save_attempt(self, plan_id, material_id, **kw):
        """Persist a workbook attempt and return the freshly-graded score.

        Body shape::

            { "answers": { "<exercise_id>": "<student answer>", ... },
              "finalize": false }

        The response is the same shape as ``GET .../attempts/me`` so the
        UI can replace its local state in one swap.
        """
        try:
            user = request.env.user
            material = self._resolve_attempt_target(plan_id, material_id, user)
            body = _get_json_body() or {}
            answers = body.get('answers') or {}
            if not isinstance(answers, dict):
                return _error_response('answers must be an object', 400)
            finalize = bool(body.get('finalize'))

            Attempt = request.env['encoach.course.plan.workbook.attempt'].sudo()
            attempt = Attempt.search([
                ('material_id', '=', material.id),
                ('student_id', '=', user.id),
            ], order='attempt_number desc', limit=1)
            if not attempt:
                attempt = Attempt.create({
                    'material_id': material.id,
                    'student_id': user.id,
                    'attempt_number': 1,
                })
            elif attempt.is_final:
                # Once finalised, a re-submit becomes a new attempt so the
                # original locked attempt survives in the audit trail.
                attempt = Attempt.create({
                    'material_id': material.id,
                    'student_id': user.id,
                    'attempt_number': attempt.attempt_number + 1,
                })
            attempt.grade(answers, finalize=finalize)
            return _json_response({'data': attempt.to_api_dict()})
        except PermissionError as exc:
            return _error_response(str(exc), 403)
        except ValueError as exc:
            return _error_response(str(exc), 404)
        except Exception as exc:
            _logger.exception('course-plan.student_save_attempt failed')
            return _error_response(str(exc), 500)

    # ------------------------------------------------------------------
    # GET /api/student/course-plans/<plan_id>/materials/<material_id>/attempts/me
    # ------------------------------------------------------------------
    @http.route(
        '/api/student/course-plans/<int:plan_id>/materials/<int:material_id>/attempts/me',
        type='http', auth='none', methods=['GET'], csrf=False,
    )
    @jwt_required
    def student_my_attempt(self, plan_id, material_id, **kw):
        try:
            user = request.env.user
            material = self._resolve_attempt_target(plan_id, material_id, user)
            attempt = request.env['encoach.course.plan.workbook.attempt'].sudo().search([
                ('material_id', '=', material.id),
                ('student_id', '=', user.id),
            ], order='attempt_number desc', limit=1)
            if not attempt:
                return _json_response({'data': None})
            return _json_response({'data': attempt.to_api_dict()})
        except PermissionError as exc:
            return _error_response(str(exc), 403)
        except ValueError as exc:
            return _error_response(str(exc), 404)
        except Exception as exc:
            _logger.exception('course-plan.student_my_attempt failed')
            return _error_response(str(exc), 500)

    # ------------------------------------------------------------------
    # GET /api/ai/course-plan/<plan_id>/materials/<material_id>/attempts
    # ------------------------------------------------------------------
    @http.route(
        '/api/ai/course-plan/<int:plan_id>/materials/<int:material_id>/attempts',
        type='http', auth='none', methods=['GET'], csrf=False,
    )
    @jwt_required
    def list_material_attempts(self, plan_id, material_id, **kw):
        """Teacher dashboard: every student's latest attempt on a material."""
        try:
            self._get_plan_scoped(plan_id)
            material = self._resolve_material(material_id)
            if not material or material.plan_id.id != int(plan_id):
                return _error_response('Material not found', 404)
            attempts = request.env['encoach.course.plan.workbook.attempt'].sudo().search([
                ('material_id', '=', material.id),
            ], order='student_id, attempt_number desc')
            # Latest attempt per student.
            latest_by_student: dict[int, object] = {}
            for a in attempts:
                latest_by_student.setdefault(a.student_id.id, a)
            items = [a.to_api_dict() for a in latest_by_student.values()]
            return _json_response({
                'items': items,
                'count': len(items),
            })
        except ValueError as exc:
            return _error_response(str(exc), 404)
        except Exception as exc:
            _logger.exception('course-plan.list_material_attempts failed')
            return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)

    # ------------------------------------------------------------------
    def _resolve_attempt_target(self, plan_id, material_id, user):
        """Verify the material exists, belongs to the plan, and the student
        actually has the plan assigned (entity-based access for non-students
        is also allowed so admins / teachers can self-test the workbook).
        """
        plan = request.env['encoach.course.plan'].sudo().browse(int(plan_id))
        if not plan.exists():
            raise ValueError('Plan not found')
        material = request.env['encoach.course.plan.material'].sudo().browse(
            int(material_id),
        )
        if not material.exists() or material.plan_id.id != plan.id:
            raise ValueError('Material not found')

        # Student assignment path.
        for a in plan.assignment_ids.filtered(lambda x: x.state == 'active'):
            if a.mode == 'students' and user.id in a.student_user_ids.ids:
                return material
            if a.mode == 'batch' and user.id in a.expand_user_ids():
                return material
            if a.mode == 'entities' and user.id in a.expand_user_ids():
                return material

        # Entity-admin / teacher fallback so they can preview-and-submit.
        entity_ids, is_super = _entity_scope()
        if is_super:
            return material
        if entity_ids and plan.entity_id and plan.entity_id.id in entity_ids:
            return material
        raise PermissionError('Plan not assigned to you')

    @http.route('/api/student/course-plans/<int:plan_id>',
                type='http', auth='none', methods=['GET'], csrf=False)
    @jwt_required
    def student_get_plan(self, plan_id, **kw):
        try:
            user = request.env.user
            plan = request.env['encoach.course.plan'].sudo().browse(int(plan_id))
            if not plan.exists():
                return _error_response('Plan not found', 404)
            allowed = False
            for a in plan.assignment_ids.filtered(lambda x: x.state == 'active'):
                if a.mode == 'students' and user.id in a.student_user_ids.ids:
                    allowed = True
                    break
                if a.mode == 'batch' and user.id in a.expand_user_ids():
                    allowed = True
                    break
                if a.mode == 'entities' and user.id in a.expand_user_ids():
                    allowed = True
                    break
            if not allowed:
                return _error_response('Plan not assigned to you', 403)
            return _json_response({
                'data': plan.to_api_dict(
                    include_weeks=True,
                    include_materials=True,
                    include_media=True,
                ),
            })
        except Exception as exc:
            _logger.exception('course-plan.student_get_plan failed')
            return _error_response(str(exc), 500)
