import logging
from odoo import http
from odoo.http import request
from odoo.addons.encoach_api.controllers.base import (
    jwt_required, _json_response, _error_response, _get_json_body, _paginate,
)

_logger = logging.getLogger(__name__)

# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

def _serialize_course(c):
    subj = getattr(c, 'encoach_subject_id', False)
    tags = getattr(c, 'encoach_tag_ids', c.env['encoach.resource.tag'])
    topics = getattr(c, 'encoach_topic_ids', c.env['encoach.topic'])
    objectives = getattr(c, 'learning_objective_ids', c.env['encoach.learning.objective'])
    enrolled_count = c.env['op.student.course'].sudo().search_count([('course_id', '=', c.id)])
    return {
        'id': c.id,
        'name': c.name or '',
        'title': c.name or '',
        'code': c.code or '',
        'description': getattr(c, 'description', '') or '',
        'status': 'active' if c.id else 'draft',
        'student_count': enrolled_count,
        'max_capacity': getattr(c, 'max_capacity', 0) or 0,
        'subject_ids': c.subject_ids.ids if hasattr(c, 'subject_ids') else [],
        'subject_names': [s.name for s in c.subject_ids] if hasattr(c, 'subject_ids') else [],
        'department_id': c.department_id.id if hasattr(c, 'department_id') and c.department_id else None,
        'department_name': c.department_id.name if hasattr(c, 'department_id') and c.department_id else '',
        'encoach_subject_id': subj.id if subj else None,
        'encoach_subject_name': subj.name if subj else '',
        'topic_ids': topics.ids,
        'topic_names': topics.mapped('name'),
        'learning_objective_ids': objectives.ids,
        'learning_objective_names': objectives.mapped('name'),
        'tag_ids': tags.ids,
        'tags': [{'id': t.id, 'name': t.name, 'color': t.color or '#6b7280'} for t in tags],
        'difficulty_level': getattr(c, 'difficulty_level', '') or '',
        'cefr_level': getattr(c, 'cefr_level', '') or '',
        'chapter_count': getattr(c, 'chapter_count', 0) or 0,
        'resource_count': getattr(c, 'resource_count', 0) or 0,
        'objective_count': getattr(c, 'objective_count', 0) or 0,
    }


def _serialize_student(s):
    partner = s.partner_id
    first = getattr(s, 'first_name', '') or (partner.name.split()[0] if partner.name else '')
    last = getattr(s, 'last_name', '') or (' '.join(partner.name.split()[1:]) if partner.name else '')
    course_ids = []
    batch_id = None
    batch_name = ''
    if hasattr(s, 'course_detail_ids'):
        for cd in s.course_detail_ids:
            if cd.course_id:
                course_ids.append(cd.course_id.id)
            if cd.batch_id and not batch_id:
                batch_id = cd.batch_id.id
                batch_name = cd.batch_id.name or ''
    return {
        'id': s.id,
        'name': partner.name or '',
        'first_name': first,
        'last_name': last,
        'email': partner.email or '',
        'phone': partner.phone or getattr(partner, 'mobile', '') or '',
        'gender': s.gender or '',
        'enrollment_date': str(s.create_date.date()) if s.create_date else None,
        'status': 'active',
        'course_ids': course_ids,
        'batch_id': batch_id,
        'batch_name': batch_name,
        'partner_id': partner.id,
        'user_id': s.user_id.id if s.user_id else None,
    }


def _serialize_teacher(f):
    partner = f.partner_id
    first = partner.name.split()[0] if partner.name else ''
    last = ' '.join(partner.name.split()[1:]) if partner.name else ''
    dept = getattr(f, 'department_id', False)
    return {
        'id': f.id,
        'name': partner.name or '',
        'first_name': first,
        'last_name': last,
        'email': partner.email or '',
        'phone': partner.phone or getattr(partner, 'mobile', '') or '',
        'gender': f.gender or '',
        'birth_date': str(f.birth_date) if hasattr(f, 'birth_date') and f.birth_date else None,
        'partner_id': partner.id,
        'department_id': dept.id if dept else None,
        'department_name': dept.name if dept else '',
        'specialization': getattr(f, 'specialization', '') or '',
        'subject_names': [sub.name for sub in f.subject_ids] if hasattr(f, 'subject_ids') else [],
    }


def _serialize_batch(b):
    SC = b.env['op.student.course'].sudo()
    student_recs = SC.search([('batch_id', '=', b.id)], limit=200)
    seen = set()
    students = []
    for sc in student_recs:
        s = sc.student_id
        if s and s.exists() and s.id not in seen:
            seen.add(s.id)
            partner = s.partner_id
            students.append({
                'id': s.id,
                'name': partner.name if partner else '',
                'email': partner.email or '' if partner else '',
            })
    return {
        'id': b.id,
        'name': b.name or '',
        'code': b.code or '',
        'course_id': b.course_id.id if b.course_id else 0,
        'course_name': b.course_id.name if b.course_id else '',
        'start_date': str(b.start_date) if b.start_date else '',
        'end_date': str(b.end_date) if b.end_date else '',
        'status': 'active',
        'max_students': getattr(b, 'max_students', 0) or 0,
        'student_count': len(students),
        'students': students,
    }


class LmsCoreController(http.Controller):

    # ── Courses ──────────────────────────────────────────────────────

    @http.route('/api/courses', type='http', auth='public', methods=['GET'], csrf=False)
    @jwt_required
    def list_courses(self, **kw):
        try:
            Course = request.env['op.course'].sudo()
            domain = []
            if kw.get('status'):
                pass  # op.course has no status field by default
            offset, limit, page = _paginate(kw)
            total = Course.search_count(domain)
            records = Course.search(domain, offset=offset, limit=limit, order='id desc')
            return _json_response({
                'items': [_serialize_course(r) for r in records],
                'data': [_serialize_course(r) for r in records],
                'total': total,
                'page': page,
                'size': limit,
            })
        except Exception as e:
            _logger.exception('list_courses failed')
            return _error_response(str(e), 500)

    @http.route('/api/courses/<int:course_id>', type='http', auth='public', methods=['GET'], csrf=False)
    @jwt_required
    def get_course(self, course_id, **kw):
        try:
            rec = request.env['op.course'].sudo().browse(course_id)
            if not rec.exists():
                return _error_response('Not found', 404)
            return _json_response({'data': _serialize_course(rec)})
        except Exception as e:
            _logger.exception('get_course failed')
            return _error_response(str(e), 500)

    @http.route('/api/courses', type='http', auth='public', methods=['POST'], csrf=False)
    @jwt_required
    def create_course(self, **kw):
        try:
            body = _get_json_body()
            vals = {
                'name': body.get('name', ''),
                'code': body.get('code', ''),
            }
            if body.get('description'):
                vals['description'] = body['description']
            if body.get('max_capacity'):
                vals['max_capacity'] = int(body['max_capacity'])
            if body.get('encoach_subject_id'):
                vals['encoach_subject_id'] = int(body['encoach_subject_id'])
            if body.get('difficulty_level'):
                vals['difficulty_level'] = body['difficulty_level']
            if body.get('cefr_level'):
                vals['cefr_level'] = body['cefr_level']
            if body.get('topic_ids'):
                vals['encoach_topic_ids'] = [(6, 0, [int(i) for i in body['topic_ids']])]
            if body.get('learning_objective_ids'):
                vals['learning_objective_ids'] = [(6, 0, [int(i) for i in body['learning_objective_ids']])]
            if body.get('tag_ids'):
                vals['encoach_tag_ids'] = [(6, 0, [int(i) for i in body['tag_ids']])]
            rec = request.env['op.course'].sudo().create(vals)
            return _json_response({'data': _serialize_course(rec)})
        except Exception as e:
            _logger.exception('create_course failed')
            return _error_response(str(e), 500)

    @http.route('/api/courses/<int:course_id>', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False)
    @jwt_required
    def update_course(self, course_id, **kw):
        try:
            rec = request.env['op.course'].sudo().browse(course_id)
            if not rec.exists():
                return _error_response('Not found', 404)
            body = _get_json_body()
            vals = {}
            for k in ('name', 'code', 'description'):
                if k in body:
                    vals[k] = body[k]
            if 'max_capacity' in body:
                vals['max_capacity'] = int(body['max_capacity'])
            if 'encoach_subject_id' in body:
                vals['encoach_subject_id'] = int(body['encoach_subject_id']) if body['encoach_subject_id'] else False
            if 'difficulty_level' in body:
                vals['difficulty_level'] = body['difficulty_level'] or False
            if 'cefr_level' in body:
                vals['cefr_level'] = body['cefr_level'] or False
            if 'topic_ids' in body:
                vals['encoach_topic_ids'] = [(6, 0, [int(i) for i in body['topic_ids']])]
            if 'learning_objective_ids' in body:
                vals['learning_objective_ids'] = [(6, 0, [int(i) for i in body['learning_objective_ids']])]
            if 'tag_ids' in body:
                vals['encoach_tag_ids'] = [(6, 0, [int(i) for i in body['tag_ids']])]
            if vals:
                rec.write(vals)
            return _json_response({'data': _serialize_course(rec)})
        except Exception as e:
            _logger.exception('update_course failed')
            return _error_response(str(e), 500)

    @http.route('/api/courses/<int:course_id>', type='http', auth='public', methods=['DELETE'], csrf=False)
    @jwt_required
    def delete_course(self, course_id, **kw):
        try:
            rec = request.env['op.course'].sudo().browse(course_id)
            if not rec.exists():
                return _json_response({'success': True})
            force = str(kw.get('force', '')).lower() in ('1', 'true', 'yes')
            enrollments = request.env['op.student.course'].sudo().search([('course_id', '=', course_id)])
            if enrollments and not force:
                return _error_response(
                    f'Cannot delete: {len(enrollments)} student enrollment(s) reference this course. '
                    f'Pass force=true to cascade-delete.', 409)
            if force and enrollments:
                enrollments.unlink()
            batches = request.env['op.batch'].sudo().search([('course_id', '=', course_id)])
            if batches and force:
                for b in batches:
                    b.course_id = False
            rec.unlink()
            return _json_response({'success': True})
        except Exception as e:
            _logger.exception('delete_course failed')
            return _error_response(str(e), 500)

    # ── Student: My Courses (enrollment-filtered) ──────────────────

    @http.route('/api/student/my-courses', type='http', auth='public', methods=['GET'], csrf=False)
    @jwt_required
    def student_my_courses(self, **kw):
        """Return only courses the current JWT user is enrolled in via op.student.course."""
        try:
            user = request.env['res.users'].sudo().browse(request.env.uid)
            student = request.env['op.student'].sudo().search([('user_id', '=', user.id)], limit=1)
            if not student:
                return _json_response({'items': [], 'total': 0})
            course_details = request.env['op.student.course'].sudo().search([
                ('student_id', '=', student.id)
            ])
            course_ids = course_details.mapped('course_id')
            items = []
            for c in course_ids:
                data = _serialize_course(c)
                cd = course_details.filtered(lambda d: d.course_id.id == c.id)
                data['batch_id'] = cd[0].batch_id.id if cd and cd[0].batch_id else None
                data['batch_name'] = cd[0].batch_id.name if cd and cd[0].batch_id else ''
                chapters = request.env['encoach.course.chapter'].sudo().search([
                    ('course_id', '=', c.id)
                ])
                total_materials = sum(ch.material_count for ch in chapters)
                progress_recs = request.env['encoach.chapter.progress'].sudo().search([
                    ('student_id', '=', student.id),
                    ('chapter_id', 'in', chapters.ids),
                ])
                completed_chapters = len(progress_recs.filtered(lambda p: p.status == 'completed'))
                data['chapter_count'] = len(chapters)
                data['total_materials'] = total_materials
                data['completed_chapters'] = completed_chapters
                data['progress'] = int((completed_chapters / len(chapters) * 100) if chapters else 0)
                items.append(data)
            return _json_response({'items': items, 'total': len(items)})
        except Exception as e:
            _logger.exception('student_my_courses failed')
            return _error_response(str(e), 500)

    # ── Enroll existing student in a course ──────────────────────

    @http.route('/api/students/<int:student_id>/enroll', type='http', auth='public', methods=['POST'], csrf=False)
    @jwt_required
    def enroll_student(self, student_id, **kw):
        """Enroll an existing student in one or more courses."""
        try:
            student = request.env['op.student'].sudo().browse(student_id)
            if not student.exists():
                return _error_response('Student not found', 404)
            body = _get_json_body()
            course_id = body.get('course_id')
            course_ids = body.get('course_ids', [])
            if course_id:
                course_ids.append(int(course_id))
            batch_id = body.get('batch_id')
            enrolled = []
            SC = request.env['op.student.course'].sudo()
            for cid in course_ids:
                cid = int(cid)
                existing = SC.search([
                    ('student_id', '=', student.id),
                    ('course_id', '=', cid),
                ], limit=1)
                if existing:
                    continue
                vals = {'student_id': student.id, 'course_id': cid}
                if batch_id:
                    vals['batch_id'] = int(batch_id)
                SC.create(vals)
                enrolled.append(cid)
            return _json_response({
                'success': True,
                'enrolled_course_ids': enrolled,
                'student': _serialize_student(student),
            })
        except Exception as e:
            _logger.exception('enroll_student failed')
            return _error_response(str(e), 500)

    # ── Bulk enroll students in a course ─────────────────────────

    @http.route('/api/courses/<int:course_id>/enroll', type='http', auth='public', methods=['POST'], csrf=False)
    @jwt_required
    def enroll_students_in_course(self, course_id, **kw):
        """Enroll multiple students (or a whole batch) into a course."""
        try:
            course = request.env['op.course'].sudo().browse(course_id)
            if not course.exists():
                return _error_response('Course not found', 404)
            body = _get_json_body()
            student_ids = [int(sid) for sid in body.get('student_ids', [])]
            batch_id = body.get('batch_id')
            if batch_id and not student_ids:
                students_in_batch = request.env['op.student'].sudo().search([
                    ('course_detail_ids.batch_id', '=', int(batch_id))
                ])
                student_ids = students_in_batch.ids
            SC = request.env['op.student.course'].sudo()
            enrolled = []
            for sid in student_ids:
                existing = SC.search([
                    ('student_id', '=', sid),
                    ('course_id', '=', course_id),
                ], limit=1)
                if existing:
                    continue
                vals = {'student_id': sid, 'course_id': course_id}
                if batch_id:
                    vals['batch_id'] = int(batch_id)
                SC.create(vals)
                enrolled.append(sid)
            return _json_response({
                'success': True,
                'enrolled_student_ids': enrolled,
                'total_enrolled': len(enrolled),
            })
        except Exception as e:
            _logger.exception('enroll_students_in_course failed')
            return _error_response(str(e), 500)

    # ── Students ─────────────────────────────────────────────────────

    @http.route('/api/students', type='http', auth='public', methods=['GET'], csrf=False)
    @jwt_required
    def list_students(self, **kw):
        try:
            Student = request.env['op.student'].sudo()
            domain = []
            if kw.get('search'):
                domain = [('partner_id.name', 'ilike', kw['search'])]
            if kw.get('batch_id'):
                domain.append(('course_detail_ids.batch_id', '=', int(kw['batch_id'])))
            offset, limit, page = _paginate(kw)
            total = Student.search_count(domain)
            records = Student.search(domain, offset=offset, limit=limit, order='id desc')
            return _json_response({
                'items': [_serialize_student(r) for r in records],
                'data': [_serialize_student(r) for r in records],
                'total': total,
                'page': page,
                'size': limit,
            })
        except Exception as e:
            _logger.exception('list_students failed')
            return _error_response(str(e), 500)

    @http.route('/api/students/<int:student_id>', type='http', auth='public', methods=['GET'], csrf=False)
    @jwt_required
    def get_student(self, student_id, **kw):
        try:
            rec = request.env['op.student'].sudo().browse(student_id)
            if not rec.exists():
                return _error_response('Not found', 404)
            return _json_response({'data': _serialize_student(rec)})
        except Exception as e:
            _logger.exception('get_student failed')
            return _error_response(str(e), 500)

    @http.route('/api/students', type='http', auth='public', methods=['POST'], csrf=False)
    @jwt_required
    def create_student(self, **kw):
        try:
            body = _get_json_body()
            first = body.get('first_name', '')
            last = body.get('last_name', '')
            name = f"{first} {last}".strip()
            partner_vals = {
                'name': name,
                'email': body.get('email', ''),
                'phone': body.get('phone', ''),
            }
            partner = request.env['res.partner'].sudo().create(partner_vals)
            student_vals = {
                'partner_id': partner.id,
                'gender': body.get('gender', ''),
            }
            if body.get('birth_date'):
                student_vals['birth_date'] = body['birth_date']
            student = request.env['op.student'].sudo().create(student_vals)
            if body.get('course_id'):
                cd_vals = {
                    'student_id': student.id,
                    'course_id': int(body['course_id']),
                }
                if body.get('batch_id'):
                    cd_vals['batch_id'] = int(body['batch_id'])
                request.env['op.student.course'].sudo().create(cd_vals)
            if body.get('create_portal_user', True) and body.get('email'):
                user = request.env['res.users'].sudo().create({
                    'name': name,
                    'login': body['email'],
                    'email': body['email'],
                    'password': body.get('password', 'student123'),
                    'partner_id': partner.id,
                })
                student.sudo().write({'user_id': user.id})
            return _json_response({'data': _serialize_student(student)})
        except Exception as e:
            _logger.exception('create_student failed')
            return _error_response(str(e), 500)

    @http.route('/api/students/<int:student_id>', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False)
    @jwt_required
    def update_student(self, student_id, **kw):
        try:
            rec = request.env['op.student'].sudo().browse(student_id)
            if not rec.exists():
                return _error_response('Not found', 404)
            body = _get_json_body()
            partner_vals = {}
            if 'first_name' in body or 'last_name' in body:
                first = body.get('first_name', rec.partner_id.name.split()[0] if rec.partner_id.name else '')
                last = body.get('last_name', ' '.join(rec.partner_id.name.split()[1:]) if rec.partner_id.name else '')
                partner_vals['name'] = f"{first} {last}".strip()
            if 'email' in body:
                partner_vals['email'] = body['email']
            if 'phone' in body:
                partner_vals['phone'] = body['phone']
            if partner_vals:
                rec.partner_id.sudo().write(partner_vals)
            student_vals = {}
            if 'gender' in body:
                student_vals['gender'] = body['gender']
            if student_vals:
                rec.write(student_vals)
            return _json_response({'data': _serialize_student(rec)})
        except Exception as e:
            _logger.exception('update_student failed')
            return _error_response(str(e), 500)

    @http.route('/api/students/<int:student_id>', type='http', auth='public', methods=['DELETE'], csrf=False)
    @jwt_required
    def delete_student(self, student_id, **kw):
        try:
            rec = request.env['op.student'].sudo().browse(student_id)
            if rec.exists():
                rec.unlink()
            return _json_response({'success': True})
        except Exception as e:
            _logger.exception('delete_student failed')
            return _error_response(str(e), 500)

    # ── Teachers ─────────────────────────────────────────────────────

    @http.route('/api/teachers', type='http', auth='public', methods=['GET'], csrf=False)
    @jwt_required
    def list_teachers(self, **kw):
        try:
            Faculty = request.env['op.faculty'].sudo()
            domain = []
            if kw.get('search'):
                domain = [('partner_id.name', 'ilike', kw['search'])]
            offset, limit, page = _paginate(kw)
            total = Faculty.search_count(domain)
            records = Faculty.search(domain, offset=offset, limit=limit, order='id desc')
            return _json_response({
                'items': [_serialize_teacher(r) for r in records],
                'data': [_serialize_teacher(r) for r in records],
                'total': total,
                'page': page,
                'size': limit,
            })
        except Exception as e:
            _logger.exception('list_teachers failed')
            return _error_response(str(e), 500)

    @http.route('/api/teachers', type='http', auth='public', methods=['POST'], csrf=False)
    @jwt_required
    def create_teacher(self, **kw):
        try:
            body = _get_json_body()
            first = body.get('first_name', '')
            last = body.get('last_name', '')
            name = f"{first} {last}".strip()
            partner = request.env['res.partner'].sudo().create({
                'name': name,
                'email': body.get('email', ''),
                'phone': body.get('phone', ''),
            })
            fac_vals = {
                'partner_id': partner.id,
                'gender': body.get('gender', ''),
            }
            if body.get('department_id') and hasattr(request.env['op.faculty'], 'department_id'):
                fac_vals['department_id'] = int(body['department_id'])
            if body.get('birth_date'):
                fac_vals['birth_date'] = body['birth_date']
            faculty = request.env['op.faculty'].sudo().create(fac_vals)
            return _json_response({'data': _serialize_teacher(faculty)})
        except Exception as e:
            _logger.exception('create_teacher failed')
            return _error_response(str(e), 500)

    @http.route('/api/teachers/<int:teacher_id>', type='http', auth='public', methods=['DELETE'], csrf=False)
    @jwt_required
    def delete_teacher(self, teacher_id, **kw):
        try:
            rec = request.env['op.faculty'].sudo().browse(teacher_id)
            if rec.exists():
                rec.unlink()
            return _json_response({'success': True})
        except Exception as e:
            _logger.exception('delete_teacher failed')
            return _error_response(str(e), 500)

    # ── Batches ──────────────────────────────────────────────────────

    @http.route('/api/batches', type='http', auth='public', methods=['GET'], csrf=False)
    @jwt_required
    def list_batches(self, **kw):
        try:
            Batch = request.env['op.batch'].sudo()
            domain = []
            offset, limit, page = _paginate(kw)
            total = Batch.search_count(domain)
            records = Batch.search(domain, offset=offset, limit=limit, order='id desc')
            return _json_response({
                'items': [_serialize_batch(r) for r in records],
                'data': [_serialize_batch(r) for r in records],
                'total': total,
                'page': page,
                'size': limit,
            })
        except Exception as e:
            _logger.exception('list_batches failed')
            return _error_response(str(e), 500)

    @http.route('/api/batches/<int:batch_id>', type='http', auth='public', methods=['GET'], csrf=False)
    @jwt_required
    def get_batch(self, batch_id, **kw):
        try:
            rec = request.env['op.batch'].sudo().browse(batch_id)
            if not rec.exists():
                return _error_response('Not found', 404)
            return _json_response({'data': _serialize_batch(rec)})
        except Exception as e:
            _logger.exception('get_batch failed')
            return _error_response(str(e), 500)

    @http.route('/api/batches', type='http', auth='public', methods=['POST'], csrf=False)
    @jwt_required
    def create_batch(self, **kw):
        try:
            body = _get_json_body()
            vals = {'name': body.get('name', '')}
            if body.get('code'):
                vals['code'] = body['code']
            if body.get('course_id'):
                vals['course_id'] = int(body['course_id'])
            if body.get('start_date'):
                vals['start_date'] = body['start_date']
            if body.get('end_date'):
                vals['end_date'] = body['end_date']
            rec = request.env['op.batch'].sudo().create(vals)
            return _json_response({'data': _serialize_batch(rec)})
        except Exception as e:
            _logger.exception('create_batch failed')
            return _error_response(str(e), 500)

    @http.route('/api/batches/<int:batch_id>', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False)
    @jwt_required
    def update_batch(self, batch_id, **kw):
        try:
            rec = request.env['op.batch'].sudo().browse(batch_id)
            if not rec.exists():
                return _error_response('Not found', 404)
            body = _get_json_body()
            vals = {}
            for k in ('name', 'code', 'start_date', 'end_date'):
                if k in body:
                    vals[k] = body[k]
            if 'course_id' in body:
                vals['course_id'] = int(body['course_id'])
            if vals:
                rec.write(vals)
            return _json_response({'data': _serialize_batch(rec)})
        except Exception as e:
            _logger.exception('update_batch failed')
            return _error_response(str(e), 500)

    @http.route('/api/batches/<int:batch_id>', type='http', auth='public', methods=['DELETE'], csrf=False)
    @jwt_required
    def delete_batch(self, batch_id, **kw):
        try:
            rec = request.env['op.batch'].sudo().browse(batch_id)
            if rec.exists():
                rec.unlink()
            return _json_response({'success': True})
        except Exception as e:
            _logger.exception('delete_batch failed')
            return _error_response(str(e), 500)

    @http.route('/api/batches/<int:batch_id>/students', type='http', auth='public', methods=['GET'], csrf=False)
    @jwt_required
    def list_batch_students(self, batch_id, **kw):
        try:
            SC = request.env['op.student.course'].sudo()
            recs = SC.search([('batch_id', '=', batch_id)])
            students = []
            for sc in recs:
                s = sc.student_id
                if s and s.exists():
                    partner = s.partner_id
                    students.append({
                        'id': s.id,
                        'name': partner.name if partner else '',
                        'email': partner.email or '' if partner else '',
                        'course_id': sc.course_id.id if sc.course_id else 0,
                        'course_name': sc.course_id.name if sc.course_id else '',
                    })
            return _json_response({'data': students, 'total': len(students)})
        except Exception as e:
            return _error_response(str(e), 500)

    @http.route('/api/batches/<int:batch_id>/students', type='http', auth='public', methods=['POST'], csrf=False)
    @jwt_required
    def add_students_to_batch(self, batch_id, **kw):
        """Add students to a batch. Creates op.student.course links with batch_id."""
        try:
            batch = request.env['op.batch'].sudo().browse(batch_id)
            if not batch.exists():
                return _error_response('Batch not found', 404)
            body = _get_json_body()
            student_ids = [int(s) for s in body.get('student_ids', [])]
            SC = request.env['op.student.course'].sudo()
            added = []
            for sid in student_ids:
                existing = SC.search([
                    ('student_id', '=', sid),
                    ('batch_id', '=', batch_id),
                ], limit=1)
                if existing:
                    continue
                vals = {'student_id': sid, 'batch_id': batch_id}
                if batch.course_id:
                    vals['course_id'] = batch.course_id.id
                    dup = SC.search([
                        ('student_id', '=', sid),
                        ('course_id', '=', batch.course_id.id),
                    ], limit=1)
                    if dup:
                        dup.write({'batch_id': batch_id})
                        added.append(sid)
                        continue
                SC.create(vals)
                added.append(sid)
            return _json_response({
                'success': True,
                'added_ids': added,
                'total': len(added),
                'data': _serialize_batch(batch),
            })
        except Exception as e:
            _logger.exception('add_students_to_batch failed')
            return _error_response(str(e), 500)

    @http.route('/api/batches/<int:batch_id>/students/remove', type='http', auth='public', methods=['POST'], csrf=False)
    @jwt_required
    def remove_students_from_batch(self, batch_id, **kw):
        """Remove students from a batch by clearing their batch_id."""
        try:
            body = _get_json_body()
            student_ids = [int(s) for s in body.get('student_ids', [])]
            SC = request.env['op.student.course'].sudo()
            removed = []
            for sid in student_ids:
                recs = SC.search([
                    ('student_id', '=', sid),
                    ('batch_id', '=', batch_id),
                ])
                if recs:
                    recs.write({'batch_id': False})
                    removed.append(sid)
            batch = request.env['op.batch'].sudo().browse(batch_id)
            return _json_response({
                'success': True,
                'removed_ids': removed,
                'total': len(removed),
                'data': _serialize_batch(batch) if batch.exists() else {},
            })
        except Exception as e:
            return _error_response(str(e), 500)

    # ── Taxonomy Relationship Endpoints ─────────────────────────────

    @http.route('/api/courses/<int:course_id>/taxonomy', type='http', auth='public', methods=['GET'], csrf=False)
    @jwt_required
    def course_taxonomy(self, course_id, **kw):
        """Return the taxonomy tree relevant to a course: its subject, topics, and objectives."""
        try:
            course = request.env['op.course'].sudo().browse(course_id)
            if not course.exists():
                return _error_response('Not found', 404)
            subj = course.encoach_subject_id if hasattr(course, 'encoach_subject_id') else False
            topics = course.encoach_topic_ids if hasattr(course, 'encoach_topic_ids') else course.env['encoach.topic']
            objectives = course.learning_objective_ids if hasattr(course, 'learning_objective_ids') else course.env['encoach.learning.objective']
            domains = topics.mapped('domain_id')
            return _json_response({
                'subject': {'id': subj.id, 'name': subj.name, 'code': subj.code or ''} if subj else None,
                'domains': [{'id': d.id, 'name': d.name} for d in domains],
                'topics': [{'id': t.id, 'name': t.name, 'domain_id': t.domain_id.id, 'domain_name': t.domain_id.name} for t in topics],
                'objectives': [{'id': o.id, 'name': o.name, 'topic_id': o.topic_id.id, 'topic_name': o.topic_id.name, 'bloom_level': o.bloom_level or ''} for o in objectives],
                'tags': [{'id': t.id, 'name': t.name, 'color': t.color or '#6b7280'} for t in (course.encoach_tag_ids if hasattr(course, 'encoach_tag_ids') else [])],
            })
        except Exception as e:
            _logger.exception('course_taxonomy failed')
            return _error_response(str(e), 500)

    @http.route('/api/subjects/<int:subject_id>/courses', type='http', auth='public', methods=['GET'], csrf=False)
    @jwt_required
    def subject_courses(self, subject_id, **kw):
        """Return all courses linked to a given taxonomy subject."""
        try:
            courses = request.env['op.course'].sudo().search([
                ('encoach_subject_id', '=', subject_id)
            ])
            return _json_response({
                'items': [_serialize_course(c) for c in courses],
                'total': len(courses),
            })
        except Exception as e:
            _logger.exception('subject_courses failed')
            return _error_response(str(e), 500)

    @http.route('/api/topics/<int:topic_id>/resources', type='http', auth='public', methods=['GET'], csrf=False)
    @jwt_required
    def topic_resources(self, topic_id, **kw):
        """Return all resources linked to a given taxonomy topic."""
        try:
            resources = request.env['encoach.resource'].sudo().search([
                ('topic_ids', 'in', [topic_id])
            ])
            from odoo.addons.encoach_lms_api.controllers.resources import _ser_resource
            return _json_response({
                'items': [_ser_resource(r) for r in resources],
                'total': len(resources),
            })
        except Exception as e:
            _logger.exception('topic_resources failed')
            return _error_response(str(e), 500)

    @http.route('/api/learning-objectives', type='http', auth='public', methods=['GET'], csrf=False)
    @jwt_required
    def list_learning_objectives(self, **kw):
        """List learning objectives, optionally filtered by topic_id or subject_id."""
        try:
            LO = request.env['encoach.learning.objective'].sudo()
            domain = [('active', '=', True)]
            if kw.get('topic_id'):
                domain.append(('topic_id', '=', int(kw['topic_id'])))
            elif kw.get('topic_ids'):
                ids = [int(x) for x in str(kw['topic_ids']).split(',') if x.strip()]
                domain.append(('topic_id', 'in', ids))
            elif kw.get('subject_id'):
                topics = request.env['encoach.topic'].sudo().search([
                    ('domain_id.subject_id', '=', int(kw['subject_id']))
                ])
                domain.append(('topic_id', 'in', topics.ids))
            recs = LO.search(domain, order='sequence, name')
            items = [{
                'id': o.id,
                'name': o.name,
                'description': o.description or '',
                'topic_id': o.topic_id.id,
                'topic_name': o.topic_id.name,
                'bloom_level': o.bloom_level or '',
                'cefr_level': o.cefr_level or '',
            } for o in recs]
            return _json_response({'items': items, 'total': len(items)})
        except Exception as e:
            _logger.exception('list_learning_objectives failed')
            return _error_response(str(e), 500)

    @http.route('/api/domains', type='http', auth='public', methods=['GET'], csrf=False)
    @jwt_required
    def list_domains_lms(self, **kw):
        """List domains, optionally filtered by subject_id."""
        try:
            D = request.env['encoach.domain'].sudo()
            domain = [('active', '=', True)]
            if kw.get('subject_id'):
                domain.append(('subject_id', '=', int(kw['subject_id'])))
            recs = D.search(domain, order='sequence, name')
            items = [{
                'id': d.id,
                'name': d.name,
                'subject_id': d.subject_id.id,
                'subject_name': d.subject_id.name,
                'description': d.description or '',
            } for d in recs]
            return _json_response({'items': items, 'total': len(items)})
        except Exception as e:
            _logger.exception('list_domains_lms failed')
            return _error_response(str(e), 500)
