"""Per-student workbook attempt — server-side scoring + persistence.

A student opens an ``interactive_workbook`` material in
``InteractiveWorkbook.tsx``, types/picks/drags answers, and either
clicks "Check answers" (debounced save) or "Submit final" (final save).
Each save creates or updates one ``encoach.course.plan.workbook.attempt``
row keyed by ``(material_id, student_id, attempt_number)``.

The score is always recomputed server-side from the persisted answers
so a tampered client cannot inflate the score. The grading function
handles each of the six exercise types defined in the plan schema:

* ``gap_fill``        — case-insensitive equality, ``alt`` list match.
* ``multiple_choice`` — exact match against ``answer`` (string or letter).
* ``match_pairs``     — set-equality of pair tuples.
* ``reorder_words``   — token-by-token equality (whitespace-tolerant).
* ``transformation``  — equality with normalised punctuation, ``alt``
  fallback, capitalisation tolerated.
* ``short_answer``    — glob-style ``accepted`` template (``*`` matches
  any phrase) plus exact ``answer`` fallback.

Entity isolation
----------------
Every attempt carries the same ``entity_id`` as the parent plan. A SQL
constraint mirrors the LMS isolation pattern: a student in entity A
cannot create an attempt against a material owned by entity B.
"""

from __future__ import annotations

import json
import logging
import re

from odoo import api, fields, models
from odoo.exceptions import ValidationError

_logger = logging.getLogger(__name__)


# ----------------------------------------------------------------------
# Scoring helpers — pure functions so the unit tests can hit them
# directly without spinning up an ORM.
# ----------------------------------------------------------------------

def _norm_text(s) -> str:
    if s is None:
        return ''
    return re.sub(r'\s+', ' ', str(s)).strip().lower()


def _norm_punct(s) -> str:
    """Normalise final punctuation/quotes for transformation checks."""
    return re.sub(r'[\s\.\?\!,]+$', '', _norm_text(s))


def _glob_match(pattern: str, value: str) -> bool:
    """Tiny ``*``-only glob matcher used by short-answer templates."""
    pat = _norm_text(pattern)
    val = _norm_text(value)
    if not pat:
        return False
    if '*' not in pat:
        return pat == val
    rx = '^' + re.escape(pat).replace(r'\*', r'.*') + '$'
    return bool(re.match(rx, val))


def _pairs_equal(answer, given) -> bool:
    """Set-equality check for match_pairs answers."""
    def _to_set(p):
        out = set()
        for it in (p or []):
            if isinstance(it, (list, tuple)) and len(it) == 2:
                try:
                    out.add((int(it[0]), int(it[1])))
                except (TypeError, ValueError):
                    return None
        return out
    a = _to_set(answer)
    g = _to_set(given)
    if a is None or g is None:
        return False
    return a == g


def _check_one(ex: dict, given) -> bool:
    """Return True iff ``given`` is a correct answer for ``ex``."""
    t = (ex.get('type') or '').lower()

    if t == 'gap_fill':
        if given is None:
            return False
        accepted = [ex.get('answer')] + list(ex.get('alt') or [])
        n = _norm_text(given)
        return any(_norm_text(a) == n for a in accepted if a is not None)

    if t == 'multiple_choice':
        opts = ex.get('options') or []
        ans = ex.get('answer')
        # The "answer" in the schema can be the literal option string
        # ("Paris") OR a single letter ("A","B",…) — accept either.
        if ans is None:
            return False
        if isinstance(given, str):
            n = _norm_text(given)
            if n == _norm_text(ans):
                return True
            # Letter-mode: convert "A"/"B" to index → option string.
            if isinstance(ans, str) and len(ans) == 1 and ans.isalpha():
                idx = ord(ans.upper()) - ord('A')
                if 0 <= idx < len(opts) and _norm_text(opts[idx]) == n:
                    return True
            if isinstance(given, str) and len(given) == 1 and given.isalpha():
                idx = ord(given.upper()) - ord('A')
                if 0 <= idx < len(opts) and _norm_text(opts[idx]) == _norm_text(ans):
                    return True
        return False

    if t == 'match_pairs':
        return _pairs_equal(ex.get('answer'), given)

    if t == 'reorder_words':
        if given is None:
            return False
        # Accept either a list of tokens or a joined string.
        if isinstance(given, list):
            given_str = ' '.join(str(t) for t in given)
        else:
            given_str = str(given)
        return _norm_text(given_str) == _norm_text(ex.get('answer'))

    if t == 'transformation':
        if given is None:
            return False
        accepted = [ex.get('answer')] + list(ex.get('alt') or [])
        n = _norm_punct(given)
        return any(_norm_punct(a) == n for a in accepted if a is not None)

    if t == 'short_answer':
        if given is None or str(given).strip() == '':
            return False
        accepted = list(ex.get('accepted') or [])
        if ex.get('answer'):
            accepted.append(str(ex['answer']))
        return any(_glob_match(p, given) for p in accepted)

    # Unknown / un-checkable type — never auto-mark correct.
    return False


def score_exercises(exercises: list[dict], answers: dict) -> dict:
    """Grade ``answers`` against ``exercises``. Pure / safe / deterministic."""
    items: list[dict] = []
    correct = 0
    for ex in exercises or []:
        eid = str(ex.get('id') or '')
        if not eid:
            continue
        given = answers.get(eid) if isinstance(answers, dict) else None
        ok = _check_one(ex, given)
        if ok:
            correct += 1
        items.append({
            'id': eid,
            'correct': bool(ok),
            'expected': ex.get('answer'),
            'given': given,
        })
    total = len(items)
    return {
        'items': items,
        'correct': correct,
        'total': total,
        'percent': round((correct / total) * 100, 1) if total else 0.0,
    }


# ----------------------------------------------------------------------
# Odoo model
# ----------------------------------------------------------------------

class CoursePlanWorkbookAttempt(models.Model):
    _name = 'encoach.course.plan.workbook.attempt'
    _description = 'Course-plan interactive workbook attempt'
    _order = 'submitted_at desc, id desc'
    _rec_name = 'display_name'

    material_id = fields.Many2one(
        'encoach.course.plan.material',
        required=True, ondelete='cascade', index=True,
    )
    plan_id = fields.Many2one(
        related='material_id.plan_id', store=True, index=True,
    )
    student_id = fields.Many2one(
        'res.users', required=True, ondelete='cascade', index=True,
        string='Student user',
    )
    entity_id = fields.Many2one(
        'encoach.entity', index=True, string='Entity',
        help='Mirrors the parent plan\'s entity for LMS isolation.',
    )
    answers_json = fields.Text(
        default='{}',
        help='Per-exercise dict keyed by exercise ID '
             '— shape mirrors the schema output by InteractiveWorkbook.tsx.',
    )
    score_json = fields.Text(
        default='{}',
        help='Server-side grading output: '
             '{items: [{id, correct, expected, given}], correct, total, percent}.',
    )
    correct_count = fields.Integer(default=0, string='Correct')
    total_count = fields.Integer(default=0, string='Total')
    percent = fields.Float(default=0.0, digits=(5, 1))
    submitted_at = fields.Datetime(
        help='Set when the student clicks "Submit final". '
             'Until then the row is editable on every "Check answers".',
    )
    last_updated_at = fields.Datetime(default=fields.Datetime.now)
    attempt_number = fields.Integer(default=1)
    is_final = fields.Boolean(default=False)
    display_name = fields.Char(compute='_compute_display_name', store=False)

    _sql_constraints = [
        (
            'workbook_attempt_unique',
            'unique(material_id, student_id, attempt_number)',
            'Only one workbook attempt per (material, student, attempt#).',
        ),
    ]

    # ------------------------------------------------------------------
    @api.depends('material_id', 'student_id', 'attempt_number')
    def _compute_display_name(self):
        for rec in self:
            mat = rec.material_id.title or 'Workbook'
            who = rec.student_id.name or rec.student_id.login or '?'
            rec.display_name = f'{mat} — {who} (attempt {rec.attempt_number})'

    # ------------------------------------------------------------------
    @api.constrains('material_id', 'entity_id')
    def _check_entity_isolation(self):
        for rec in self:
            plan = rec.material_id.plan_id
            plan_entity = plan.entity_id.id if (plan and plan.entity_id) else False
            if rec.entity_id and plan_entity and rec.entity_id.id != plan_entity:
                raise ValidationError(
                    'Workbook attempt entity must match its plan\'s entity.'
                )

    # ------------------------------------------------------------------
    @api.model_create_multi
    def create(self, vals_list):
        for vals in vals_list:
            mat = self.env['encoach.course.plan.material'].sudo().browse(
                int(vals.get('material_id') or 0),
            )
            if mat and mat.exists() and not vals.get('entity_id'):
                vals['entity_id'] = (
                    mat.plan_id.entity_id.id if mat.plan_id and mat.plan_id.entity_id
                    else False
                )
        return super().create(vals_list)

    # ------------------------------------------------------------------
    @staticmethod
    def _exercises_from_material(material) -> list[dict]:
        try:
            body = json.loads(material.body_json or '{}')
        except (TypeError, ValueError):
            return []
        ex = body.get('exercises')
        if isinstance(ex, list):
            return ex
        # Some skill bodies (grammar / vocabulary) embed the workbook
        # one level deeper. Search a couple of known keys before giving up.
        nested = (
            (body.get('interactive_workbook') or {}).get('exercises')
            if isinstance(body.get('interactive_workbook'), dict) else None
        )
        if isinstance(nested, list):
            return nested
        return []

    # ------------------------------------------------------------------
    def grade(self, answers: dict, *, finalize: bool = False) -> dict:
        """Re-grade ``answers`` server-side and persist on this row."""
        self.ensure_one()
        exercises = self._exercises_from_material(self.material_id)
        score = score_exercises(exercises, answers or {})
        vals = {
            'answers_json': json.dumps(answers or {}, ensure_ascii=False),
            'score_json': json.dumps(score, ensure_ascii=False),
            'correct_count': score['correct'],
            'total_count': score['total'],
            'percent': score['percent'],
            'last_updated_at': fields.Datetime.now(),
        }
        if finalize:
            vals['submitted_at'] = fields.Datetime.now()
            vals['is_final'] = True
        self.write(vals)
        return score

    # ------------------------------------------------------------------
    def to_api_dict(self) -> dict:
        self.ensure_one()
        try:
            answers = json.loads(self.answers_json or '{}')
        except (TypeError, ValueError):
            answers = {}
        try:
            score = json.loads(self.score_json or '{}')
        except (TypeError, ValueError):
            score = {}
        return {
            'id': self.id,
            'material_id': self.material_id.id,
            'plan_id': self.plan_id.id if self.plan_id else None,
            'student_id': self.student_id.id,
            'student_name': self.student_id.name or self.student_id.login or '',
            'attempt_number': self.attempt_number,
            'is_final': bool(self.is_final),
            'submitted_at': (
                self.submitted_at.isoformat() if self.submitted_at else None
            ),
            'last_updated_at': (
                self.last_updated_at.isoformat() if self.last_updated_at else None
            ),
            'correct_count': self.correct_count,
            'total_count': self.total_count,
            'percent': self.percent,
            'answers': answers,
            'score': score,
        }
