"""Reports API.

Serves the admin "Reports" section in the sidebar:
  * Student Performance  -> /api/reports/student-performance
  * Stats Corporate      -> /api/reports/stats-corporate
  * Record               -> /api/reports/record

All endpoints aggregate from ``encoach.student.attempt`` (exam attempts with
per-skill band scores and CEFR level) and its surrounding records
(``res.users`` for student names, ``encoach.entity`` for corporate context,
``encoach.exam.custom`` for exam metadata).

A single attempt is "reportable" when it has a completed/scored/released
status AND at least one non-null band. Everything else is ignored so the
Reports pages never surface misleading zeros for in-progress work.
"""
import logging
from collections import defaultdict
from datetime import datetime, timedelta

from odoo import http
from odoo.http import request
from odoo.addons.encoach_api.controllers.base import (
    jwt_required, _json_response, _error_response, _paginate,
)
from odoo.addons.encoach_api.utils.cache import get as _cache_get, put as _cache_put

_logger = logging.getLogger(__name__)

REPORTABLE_STATUSES = ('completed', 'scoring', 'scored', 'released')

from odoo.addons.encoach_ai.services.cefr_mapper import (
    band_to_cefr as _cefr_code_for_band,
    normalize_cefr as _normalize_cefr,
)


def _cefr_for_band(band):
    """Return the display-form CEFR label (e.g. 'B2') for an IELTS band."""
    code = _cefr_code_for_band(band)
    if code is None:
        return None
    return _normalize_cefr(code) or code


def _duration_minutes(att):
    start = att.started_at
    end = None
    if hasattr(att, 'completed_at'):
        end = att.completed_at
    if not end and hasattr(att, 'finished_at'):
        end = att.finished_at
    if not start or not end:
        return None
    return int(max(0, (end - start).total_seconds() // 60))


def _attempt_completed_at(att):
    if hasattr(att, 'completed_at') and att.completed_at:
        return att.completed_at
    if hasattr(att, 'finished_at') and att.finished_at:
        return att.finished_at
    return None


def _allowed_entity_ids(env):
    """Return the set of entity ids the calling user is allowed to see.

    * Admins / system users: ``None`` (unrestricted — they may pass any
      ``entity_id`` query param to scope manually).
    * Corporate / master-corporate / teacher / student: the entity ids
      linked to ``res.users.entity_ids`` on their record. Empty set means
      "no entities, see nothing" (defensive — better than leaking).
    """
    user = env.user
    if not user or not user.id:
        return set()
    user_type = getattr(user, 'user_type', None)
    if user_type == 'admin' or user.has_group('base.group_system'):
        return None  # unrestricted
    try:
        return set((user.entity_ids or env['encoach.entity']).ids)
    except Exception:
        return set()


def _build_attempt_domain(kw, reportable=True):
    """Common filter reader used by all three endpoints.

    Always enforces the caller's allowed entity scope as a default
    domain so corporate users can never see another company's data —
    even if they omit ``entity_id`` or pass one outside their allow-list.
    Admins are unrestricted and may pass any ``entity_id``.
    """
    from odoo.http import request as _req
    domain = []
    if reportable:
        domain.append(('status', 'in', list(REPORTABLE_STATUSES)))

    allowed = _allowed_entity_ids(_req.env)
    requested_entity = kw.get('entity_id')
    requested_int = None
    if requested_entity:
        try:
            requested_int = int(requested_entity)
        except (TypeError, ValueError):
            requested_int = None

    if allowed is None:
        # Admin: honour the requested entity_id verbatim, no scoping.
        if requested_int is not None:
            domain.append(('entity_id', '=', requested_int))
    else:
        # Non-admin: clamp the requested entity to the allow-list. If
        # they didn't request one, scope to all of theirs. If their
        # request is outside the allow-list, return an empty result set
        # by appending an impossible domain — never a 200 with leaked
        # data.
        if requested_int is not None:
            if requested_int in allowed:
                domain.append(('entity_id', '=', requested_int))
            else:
                domain.append(('entity_id', '=', -1))  # forces empty
        else:
            if allowed:
                domain.append(('entity_id', 'in', list(allowed)))
            else:
                domain.append(('entity_id', '=', -1))  # no entities, no data

    user_id = kw.get('user_id') or kw.get('student_id')
    if user_id:
        try:
            domain.append(('student_id', '=', int(user_id)))
        except (TypeError, ValueError):
            pass
    since = kw.get('since')
    if since:
        try:
            domain.append(('started_at', '>=', since))
        except Exception:
            pass
    period = (kw.get('period') or '').lower()
    if period in ('day', 'week', 'month'):
        delta = {'day': 1, 'week': 7, 'month': 30}[period]
        cutoff = datetime.now() - timedelta(days=delta)
        domain.append(('started_at', '>=', cutoff))
    return domain


class ReportsController(http.Controller):

    # ── 1. Student Performance ───────────────────────────────────────

    @http.route('/api/reports/student-performance', type='http',
                auth='public', methods=['GET'], csrf=False)
    @jwt_required
    def student_performance(self, **kw):
        """Return one row per student with averaged bands per skill.

        Query params: ``entity_id``, ``level`` (CEFR code, case-insensitive),
        ``search`` (matches student name/login), ``since`` (ISO date).
        """
        try:
            Att = request.env['encoach.student.attempt'].sudo()
            domain = _build_attempt_domain(kw)
            attempts = Att.search(domain, order='started_at desc')

            per_student = defaultdict(lambda: {
                'attempts': [],
                'reading_sum': 0.0, 'reading_n': 0,
                'listening_sum': 0.0, 'listening_n': 0,
                'writing_sum': 0.0, 'writing_n': 0,
                'speaking_sum': 0.0, 'speaking_n': 0,
                'overall_sum': 0.0, 'overall_n': 0,
                'last_at': None, 'last_cefr': None,
                'entity_id': None, 'entity_name': None,
            })

            def _accum(agg, key, value):
                if value is not None and value > 0:
                    agg[f'{key}_sum'] += float(value)
                    agg[f'{key}_n'] += 1

            for att in attempts:
                if not att.student_id:
                    continue
                agg = per_student[att.student_id.id]
                agg['attempts'].append(att.id)
                _accum(agg, 'reading', att.reading_band)
                _accum(agg, 'listening', att.listening_band)
                _accum(agg, 'writing', att.writing_band)
                _accum(agg, 'speaking', att.speaking_band)
                _accum(agg, 'overall', att.overall_band)
                ct = _attempt_completed_at(att) or att.started_at
                if ct and (agg['last_at'] is None or ct > agg['last_at']):
                    agg['last_at'] = ct
                    agg['last_cefr'] = _normalize_cefr(att.cefr_level) \
                                       or _cefr_for_band(att.overall_band)
                if att.entity_id and not agg['entity_id']:
                    agg['entity_id'] = att.entity_id.id
                    agg['entity_name'] = att.entity_id.name

            search_lc = (kw.get('search') or '').strip().lower()
            level_filter = (kw.get('level') or '').strip().upper().replace('-', '_')

            rows = []
            for student_id, agg in per_student.items():
                user = request.env['res.users'].sudo().browse(student_id)
                if not user.exists():
                    continue
                name = user.name or user.login or f'User #{student_id}'
                if search_lc and search_lc not in (name or '').lower() \
                        and search_lc not in (user.login or '').lower():
                    continue

                def _avg(key):
                    n = agg[f'{key}_n']
                    if not n:
                        return None
                    return round(agg[f'{key}_sum'] / n, 2)

                overall = _avg('overall')
                if overall is None:
                    skill_avgs = [v for v in (
                        _avg('reading'), _avg('listening'),
                        _avg('writing'), _avg('speaking'),
                    ) if v is not None]
                    overall = round(sum(skill_avgs) / len(skill_avgs), 2) \
                        if skill_avgs else None

                level = agg['last_cefr'] or _cefr_for_band(overall)
                if level_filter and (level or '').upper().replace('-', '_') \
                        != level_filter:
                    continue

                rows.append({
                    'student_id': student_id,
                    'student_name': name,
                    'login': user.login or '',
                    'entity_id': agg['entity_id'],
                    'entity_name': agg['entity_name'] or '',
                    'reading': _avg('reading'),
                    'listening': _avg('listening'),
                    'writing': _avg('writing'),
                    'speaking': _avg('speaking'),
                    'overall': overall,
                    'level': level,
                    'attempts_count': len(agg['attempts']),
                    'last_attempt_at': agg['last_at'].isoformat()
                                         if agg['last_at'] else None,
                })

            rows.sort(key=lambda r: (-(r['overall'] or 0.0),
                                     r['student_name'].lower()))

            return _json_response({
                'items': rows,
                'total': len(rows),
            })
        except Exception as e:
            _logger.exception('student-performance report failed')
            return _error_response(str(e), 500)

    # ── 2. Stats Corporate ───────────────────────────────────────────

    @http.route('/api/reports/stats-corporate', type='http', auth='public',
                methods=['GET'], csrf=False)
    @jwt_required
    def stats_corporate(self, **kw):
        """Corporate rollups: avg by module, monthly trend, level distribution,
        entity comparison.

        Query params: ``entity_id``, ``threshold`` (0..100, minimum band*10 to
        include), ``months`` (trend horizon; default 6).
        """
        try:
            # 30s TTL cache: the stats dashboard is pinged from multiple widgets
            # on every page view. Invalidated implicitly once the TTL expires;
            # write-heavy endpoints (exam release, attempt score commit) can
            # call ``cache.invalidate("reports.stats_corporate")`` if we ever
            # need stronger consistency.
            cache_key = (
                "reports.stats_corporate:"
                f"entity={kw.get('entity_id') or ''};"
                f"user={kw.get('user_id') or kw.get('student_id') or ''};"
                f"thr={kw.get('threshold') or ''};"
                f"months={kw.get('months') or ''};"
                f"period={kw.get('period') or ''}"
            )
            cached = _cache_get(cache_key)
            if cached is not None:
                return _json_response(cached)

            Att = request.env['encoach.student.attempt'].sudo()
            domain = _build_attempt_domain(kw)

            try:
                threshold = float(kw.get('threshold') or 0)
            except (TypeError, ValueError):
                threshold = 0.0
            if threshold > 0:
                domain.append(('overall_band', '>=', threshold / 10.0))

            # --- Per-skill average via SQL aggregation ------------------
            # Previously each of the four skill averages required pulling the
            # full attempt rowset into Python and summing per record. This now
            # runs as a single ``read_group`` per skill (avg in SQL), dropping
            # the hot path from O(N) Python iterations to a constant number of
            # round-trips regardless of attempt volume.
            by_module = []
            skill_attempts_considered = 0
            for display, fname in (
                ('Reading', 'reading_band'),
                ('Listening', 'listening_band'),
                ('Writing', 'writing_band'),
                ('Speaking', 'speaking_band'),
            ):
                agg_domain = domain + [(fname, '>', 0)]
                rows = Att.read_group(
                    agg_domain,
                    fields=[f'{fname}:avg'],
                    groupby=[],
                )
                if rows:
                    row = rows[0]
                    avg = row.get(fname) or 0
                    n = row.get('__count') or Att.search_count(agg_domain)
                else:
                    avg, n = 0, 0
                by_module.append({
                    'module': display,
                    'score': round(avg * 10, 1) if n else 0,
                    'n': n,
                })
                skill_attempts_considered = max(skill_attempts_considered, n)

            # --- Monthly trend via read_group by month ------------------
            try:
                months = max(1, min(24, int(kw.get('months') or 6)))
            except (TypeError, ValueError):
                months = 6
            trend_domain = domain + [('overall_band', '!=', False)]
            month_rows = Att.read_group(
                trend_domain,
                fields=['overall_band:avg'],
                groupby=['started_at:month'],
                orderby='started_at:month asc',
            )
            month_buckets = {}
            for row in month_rows:
                label = row.get('started_at:month') or row.get('started_at_month')
                if not label:
                    continue
                try:
                    ref = datetime.strptime(label, '%B %Y')
                except ValueError:
                    continue
                key = ref.strftime('%Y-%m')
                month_buckets[key] = (row.get('overall_band') or 0.0,
                                      row.get('__count') or 0)

            trend = []
            today = datetime.now().replace(day=1)
            for i in range(months - 1, -1, -1):
                ref = (today - timedelta(days=31 * i)).replace(day=1)
                key = ref.strftime('%Y-%m')
                avg_val, n = month_buckets.get(key, (0.0, 0))
                trend.append({
                    'month': ref.strftime('%b'),
                    'avg': round(avg_val * 10, 1) if n else 0,
                    'period': key,
                    'n': n,
                })

            # --- CEFR distribution via read_group by cefr_level ---------
            dist_rows = Att.read_group(
                domain + [('cefr_level', '!=', False)],
                fields=[],
                groupby=['cefr_level'],
            )
            level_buckets = {}
            for row in dist_rows:
                label = _normalize_cefr(row.get('cefr_level'))
                if label:
                    level_buckets[label] = level_buckets.get(label, 0) + row.get('__count', 0)
            palette = {
                'Pre-A1': 'hsl(0, 0%, 55%)',
                'A1': 'hsl(0, 72%, 51%)',
                'A2': 'hsl(38, 92%, 50%)',
                'B1': 'hsl(199, 89%, 48%)',
                'B2': 'hsl(243, 75%, 59%)',
                'C1': 'hsl(142, 71%, 45%)',
                'C2': 'hsl(280, 65%, 50%)',
            }
            distribution = [
                {'name': lvl, 'value': level_buckets.get(lvl, 0),
                 'color': palette[lvl]}
                for lvl in ('A1', 'A2', 'B1', 'B2', 'C1', 'C2')
                if level_buckets.get(lvl)
            ] or [
                {'name': lvl, 'value': 0, 'color': palette[lvl]}
                for lvl in ('A1', 'A2', 'B1', 'B2', 'C1', 'C2')
            ]

            # --- Entity comparison via read_group by entity_id ----------
            entity_rows = Att.read_group(
                domain + [('overall_band', '!=', False)],
                fields=['overall_band:avg'],
                groupby=['entity_id'],
            )
            comparison = []
            for row in entity_rows:
                entity = row.get('entity_id') or (None, 'Unassigned')
                eid, ename = (entity[0], entity[1]) if isinstance(entity, (list, tuple)) else (None, 'Unassigned')
                avg_val = row.get('overall_band') or 0
                comparison.append({
                    'entity_id': eid,
                    'entity_name': ename or 'Unassigned',
                    'avg': round(avg_val * 10, 1),
                    'attempts': row.get('__count') or 0,
                })
            comparison.sort(key=lambda r: -r['avg'])

            total_considered = Att.search_count(domain)
            payload = {
                'by_module': by_module,
                'trend': trend,
                'distribution': distribution,
                'comparison': comparison,
                'meta': {
                    'attempts_considered': total_considered,
                    'threshold': threshold,
                    'months': months,
                },
            }
            _cache_put(cache_key, payload, ttl_seconds=30)
            return _json_response(payload)
        except Exception as e:
            _logger.exception('stats-corporate report failed')
            return _error_response(str(e), 500)

    # ── 3. Record ────────────────────────────────────────────────────

    @http.route('/api/reports/record', type='http', auth='public',
                methods=['GET'], csrf=False)
    @jwt_required
    def record(self, **kw):
        """Per-user attempt history.

        Query params: ``user_id`` (required for focused view; omit to see all),
        ``entity_id``, ``period`` (day|week|month), ``status``, pagination.
        """
        try:
            Att = request.env['encoach.student.attempt'].sudo()
            domain = _build_attempt_domain(kw, reportable=False)
            if kw.get('status'):
                domain.append(('status', '=', kw['status']))

            offset, limit, page = _paginate(kw)
            total = Att.search_count(domain)
            attempts = Att.search(domain, offset=offset, limit=limit,
                                  order='started_at desc')

            items = []
            for att in attempts:
                exam = att.exam_id
                exam_title = ''
                if exam:
                    # encoach.exam.custom labels its record with `title`,
                    # not `name`. Fall back to display_name if the field
                    # is ever renamed upstream.
                    exam_title = getattr(exam, 'title', None) \
                                  or getattr(exam, 'display_name', '') or ''
                assignment = request.env['encoach.exam.assignment'].sudo() \
                    .search([('exam_id', '=', exam.id),
                             ('student_id', '=', att.student_id.id)],
                            limit=1) if exam and att.student_id else None
                duration_min = _duration_minutes(att)
                ct = _attempt_completed_at(att) or att.started_at
                items.append({
                    'id': att.id,
                    'student_id': att.student_id.id or None,
                    'student_name': att.student_id.name if att.student_id else '',
                    'assignment': exam_title,
                    'assignment_id': assignment.id if assignment else None,
                    'exam': exam_title,
                    'exam_code': f'EX-{exam.id:03d}' if exam else '',
                    'exam_id': exam.id or None,
                    'entity_id': att.entity_id.id or None,
                    'entity_name': att.entity_id.name if att.entity_id else '',
                    'date': ct.strftime('%Y-%m-%d') if ct else None,
                    'started_at': att.started_at.isoformat()
                                    if att.started_at else None,
                    'completed_at': (_attempt_completed_at(att).isoformat()
                                     if _attempt_completed_at(att) else None),
                    'score': att.overall_band if att.overall_band else None,
                    'duration_min': duration_min,
                    'duration': f'{duration_min} min' if duration_min is not None
                                  else '—',
                    'status': att.status or 'in_progress',
                    'status_label': (att.status or 'in_progress').replace('_', ' ').title(),
                    'cefr_level': _normalize_cefr(att.cefr_level)
                                    or _cefr_for_band(att.overall_band),
                })

            return _json_response({
                'items': items,
                'total': total,
                'page': page,
                'size': limit,
            })
        except Exception as e:
            _logger.exception('record report failed')
            return _error_response(str(e), 500)

    # ── Shared picker: entities and students for report filters ──────

    @http.route('/api/reports/filters', type='http', auth='public',
                methods=['GET'], csrf=False)
    @jwt_required
    def filters(self, **kw):
        """Lightweight list of entities + students/users we have attempts for.
        Used by the filter dropdowns on all three Reports pages."""
        try:
            Ent = request.env['encoach.entity'].sudo()
            entities = [{'id': e.id, 'name': e.name or ''}
                        for e in Ent.search([], order='name')]

            Att = request.env['encoach.student.attempt'].sudo()
            att_students = Att.search([]).mapped('student_id')
            students = [{'id': u.id, 'name': u.name or u.login or '',
                         'login': u.login or ''}
                        for u in att_students.sorted('name')
                        if u and u.id > 0]

            return _json_response({
                'entities': entities,
                'students': students,
            })
        except Exception as e:
            _logger.exception('reports filters failed')
            return _error_response(str(e), 500)
