"""Validation + quality gate for AI-generated exam content.

This module is the single entry point used by exam-generation controllers to:

1. **Schema-validate** raw LLM output before any DB insert (``validate_payload``),
   catching malformed AI responses (missing prompt, empty options for MCQ,
   wrong correct_answer format, etc.) before they corrupt the question pool.

2. **Score content quality** of persisted questions (``score_question``), wrapping
   :class:`encoach_quality_gate.services.quality_checker.QualityChecker` and
   :class:`encoach_ielts_validation.services.validator.IeltsValidator` with
   graceful degradation when those addons are not installed or their optional
   Python deps (``textstat``, ``language_tool_python``) are missing.

Design choices:

- Zero hard dependency on ``jsonschema`` or ``pydantic`` — the schema is a
  lightweight, introspectable dict used by :func:`_validate_dict`. Good enough
  for the shapes we actually emit; keeps our external-deps footprint small.
- Quality scoring is best-effort and **never** raises to callers — failures
  degrade to a ``warn`` verdict so the HTTP request still completes.
"""

from __future__ import annotations

import hashlib
import json
import logging
from typing import Any

_logger = logging.getLogger(__name__)

VERDICT_OK = "ok"
VERDICT_WARN = "warn"
VERDICT_FAIL = "fail"

MIN_STEM_LEN = 8
MIN_QUALITY_SCORE = 0.55

_VALID_QUESTION_TYPES = {
    "mcq", "mcq_multi", "tfng", "ynng", "gap_fill", "short_answer",
    "form_completion", "note_completion", "map_labelling", "matching",
    "summary_completion", "heading_matching", "matching_features",
    "numerical", "expression", "code_completion", "code_output",
    "sql_query", "matrix", "graph",
}

_SKILL_VALUES = {
    "listening", "reading", "writing", "speaking", "grammar",
    "vocabulary", "math", "it",
}


EXERCISE_SCHEMA: dict[str, dict[str, Any]] = {
    "prompt": {"type": str, "required": True, "min_len": MIN_STEM_LEN},
    "type": {"type": str, "required": False, "choices": _VALID_QUESTION_TYPES | {
        "multiple_choice", "true_false", "fill_blanks", "matching_headings",
        "paragraph_match", "sentence_completion", "matching_information",
    }},
    "options": {"type": list, "required": False},
    "correct_answer": {"type": (str, list, int, float), "required": False},
    "marks": {"type": (int, float), "required": False, "min": 0, "max": 20},
    "cefr_level": {"type": str, "required": False},
}

MODULE_SCHEMA: dict[str, dict[str, Any]] = {
    "difficulty": {"type": (list, str), "required": False},
    "timer": {"type": (int, float), "required": False, "min": 0, "max": 600},
    "totalMarks": {"type": (int, float), "required": False, "min": 0, "max": 500},
    "passages": {"type": list, "required": False},
    "sections": {"type": list, "required": False},
    "tasks": {"type": list, "required": False},
    "parts": {"type": list, "required": False},
}


def _validate_dict(obj: Any, schema: dict, path: str = "") -> list[str]:
    """Return list of human-readable validation errors for ``obj`` against ``schema``."""
    errors: list[str] = []
    if not isinstance(obj, dict):
        errors.append(f"{path or 'root'}: expected object, got {type(obj).__name__}")
        return errors

    for key, rule in schema.items():
        here = f"{path}.{key}" if path else key
        if key not in obj or obj[key] in (None, ""):
            if rule.get("required"):
                errors.append(f"{here}: missing required field")
            continue

        val = obj[key]
        expected_type = rule.get("type")
        if expected_type and not isinstance(val, expected_type):
            got = type(val).__name__
            want = (
                expected_type.__name__ if isinstance(expected_type, type)
                else "/".join(t.__name__ for t in expected_type)
            )
            errors.append(f"{here}: expected {want}, got {got}")
            continue

        if isinstance(val, str):
            min_len = rule.get("min_len")
            if min_len and len(val.strip()) < min_len:
                errors.append(f"{here}: too short (< {min_len} chars)")
            choices = rule.get("choices")
            if choices and val not in choices:
                errors.append(f"{here}: '{val}' not in allowed values")
        if isinstance(val, (int, float)):
            if "min" in rule and val < rule["min"]:
                errors.append(f"{here}: {val} < min {rule['min']}")
            if "max" in rule and val > rule["max"]:
                errors.append(f"{here}: {val} > max {rule['max']}")
    return errors


def _validate_exercise(ex: Any, path: str = "exercise") -> list[str]:
    """Validate a single AI-generated exercise dict."""
    errors = _validate_dict(ex, EXERCISE_SCHEMA, path)
    if errors:
        return errors
    q_type = (ex.get("type") or "mcq").lower()
    options = ex.get("options") or []
    correct = ex.get("correct_answer")
    if q_type in ("mcq", "multiple_choice"):
        if not options or len(options) < 2:
            errors.append(f"{path}.options: MCQ requires >= 2 options")
        if correct in (None, ""):
            errors.append(f"{path}.correct_answer: MCQ requires a correct_answer")
    if q_type in ("mcq_multi",):
        if not isinstance(correct, list) or len(correct) < 1:
            errors.append(f"{path}.correct_answer: mcq_multi requires a list of answers")
    if q_type in ("true_false", "tfng", "ynng"):
        if isinstance(correct, str) and correct.lower() not in {
            "true", "false", "not_given", "yes", "no", "t", "f"
        }:
            errors.append(f"{path}.correct_answer: '{correct}' not a valid T/F/NG answer")
    return errors


def validate_payload(payload: Any) -> dict[str, Any]:
    """Validate an entire exam-generation submit payload.

    Expected shape (best-effort, since the frontend already normalizes it):

    .. code-block:: text

        {
          "title": str (required),
          "modules": {
            "<skill>": {
              "passages": [{"text": str, "exercises": [ExerciseSchema, ...]}],
              "sections": [...], "tasks": [...], "parts": [...],
              ...
            },
            ...
          }
        }

    Returns a dict with::

        {"verdict": "ok"|"warn"|"fail", "errors": [...], "warnings": [...]}

    ``verdict == "fail"`` means the caller MUST NOT persist this payload as-is.
    """
    errors: list[str] = []
    warnings: list[str] = []

    if not isinstance(payload, dict):
        return {"verdict": VERDICT_FAIL,
                "errors": [f"payload: expected object, got {type(payload).__name__}"],
                "warnings": []}

    title = (payload.get("title") or "").strip()
    if not title:
        errors.append("title: missing required field")

    modules = payload.get("modules") or {}
    if not isinstance(modules, dict) or not modules:
        errors.append("modules: missing or empty — nothing to persist")
        return {"verdict": VERDICT_FAIL, "errors": errors, "warnings": warnings}

    total_exercises = 0
    for mod_key, mod in modules.items():
        mpath = f"modules.{mod_key}"
        if not isinstance(mod, dict):
            errors.append(f"{mpath}: expected object")
            continue
        errors.extend(_validate_dict(mod, MODULE_SCHEMA, mpath))
        for p_idx, passage in enumerate(mod.get("passages") or []):
            if not isinstance(passage, dict):
                errors.append(f"{mpath}.passages[{p_idx}]: expected object")
                continue
            for e_idx, ex in enumerate(passage.get("exercises") or []):
                total_exercises += 1
                errors.extend(_validate_exercise(
                    ex, f"{mpath}.passages[{p_idx}].exercises[{e_idx}]"))
        for s_idx, sec in enumerate(mod.get("sections") or []):
            if not isinstance(sec, dict):
                errors.append(f"{mpath}.sections[{s_idx}]: expected object")
                continue
            for e_idx, ex in enumerate(sec.get("exercises") or []):
                total_exercises += 1
                errors.extend(_validate_exercise(
                    ex, f"{mpath}.sections[{s_idx}].exercises[{e_idx}]"))

    if total_exercises == 0 and not any(
        (m.get("tasks") or m.get("parts")) for m in modules.values() if isinstance(m, dict)
    ):
        warnings.append("no exercises/tasks/parts found — exam will have no questions")

    if errors:
        verdict = VERDICT_FAIL
    elif warnings:
        verdict = VERDICT_WARN
    else:
        verdict = VERDICT_OK
    return {"verdict": verdict, "errors": errors, "warnings": warnings,
            "total_exercises": total_exercises}


def prompt_hash(prompt: str) -> str:
    """SHA-256 hex digest of a rendered prompt, for provenance tracking."""
    return hashlib.sha256((prompt or "").encode("utf-8")).hexdigest()


def score_question(stem: str, *, skill: str | None = None,
                   cefr_level: str | None = None) -> dict[str, Any]:
    """Best-effort quality score for a persisted question stem.

    Wraps QualityChecker + IeltsValidator defensively — any import/runtime
    failure downgrades the verdict to ``warn`` without raising. Returns::

        {
          "verdict": "ok"|"warn"|"fail",
          "score":  float in [0, 1],
          "readability": {...} | None,
          "grammar_issues": int,
          "checks_ran": ["readability", "grammar", ...],
          "errors": [str, ...],
        }
    """
    report: dict[str, Any] = {
        "verdict": VERDICT_OK,
        "score": 1.0,
        "readability": None,
        "grammar_issues": 0,
        "checks_ran": [],
        "errors": [],
    }
    if not stem or len(stem.strip()) < MIN_STEM_LEN:
        report["verdict"] = VERDICT_FAIL
        report["score"] = 0.0
        report["errors"].append(f"stem shorter than {MIN_STEM_LEN} chars")
        return report

    try:
        from odoo.addons.encoach_quality_gate.services.quality_checker import QualityChecker
    except Exception as exc:
        report["verdict"] = VERDICT_WARN
        report["errors"].append(f"quality_gate unavailable: {exc}")
        return report

    try:
        readability = QualityChecker.check_readability(stem)
        report["readability"] = readability
        report["checks_ran"].append("readability")
        if cefr_level:
            cefr_match = QualityChecker.check_cefr_alignment(stem, cefr_level.lower())
            report["cefr_alignment"] = cefr_match
            report["checks_ran"].append("cefr_alignment")
            if not cefr_match.get("aligned", True):
                report["score"] -= 0.2
        grammar = QualityChecker.check_grammar(stem)
        report["grammar_issues"] = len(grammar.get("issues", [])) if isinstance(grammar, dict) else 0
        report["checks_ran"].append("grammar")
        if report["grammar_issues"] > 3:
            report["score"] -= 0.2
    except Exception as exc:
        _logger.warning("QualityChecker failed for stem (skill=%s): %s", skill, exc)
        report["verdict"] = VERDICT_WARN
        report["errors"].append(f"quality_checker error: {exc}")

    report["score"] = max(0.0, min(1.0, report["score"]))
    if report["verdict"] == VERDICT_OK and report["score"] < MIN_QUALITY_SCORE:
        report["verdict"] = VERDICT_WARN
    return report


def summarize_question_reports(reports: list[dict]) -> dict[str, Any]:
    """Reduce a list of per-question reports to an aggregate verdict."""
    if not reports:
        return {"verdict": VERDICT_OK, "total": 0, "failed": 0,
                "warned": 0, "avg_score": 1.0}
    failed = sum(1 for r in reports if r.get("verdict") == VERDICT_FAIL)
    warned = sum(1 for r in reports if r.get("verdict") == VERDICT_WARN)
    avg = sum(float(r.get("score") or 0) for r in reports) / max(1, len(reports))
    if failed:
        verdict = VERDICT_FAIL
    elif warned:
        verdict = VERDICT_WARN
    else:
        verdict = VERDICT_OK
    return {
        "verdict": verdict,
        "total": len(reports),
        "failed": failed,
        "warned": warned,
        "avg_score": round(avg, 3),
    }


def dumps_report(report: dict) -> str:
    """Compact JSON dump safe for storing in a Text column."""
    try:
        return json.dumps(report, ensure_ascii=False, default=str)
    except Exception:
        return "{}"
