#!/usr/bin/env python3
"""Canonical staged-content schema contracts and runtime validator.

The validator is intentionally local and conservative: it validates the
artifact shape that ASUCB write paths consume before any Canvas write can run.
Warnings report missing optional metadata or drift-prone shapes; errors block.

Canonical field-name convention (quizzes)
-----------------------------------------
Staged quiz question feedback fields use the PLAIN names: ``correct_comments``,
``incorrect_comments``, ``neutral_comments`` — matching the output of
``fetch_canvas_to_stage.py``. Renderers (``staging_server._render_quiz``,
``unified_staging_review.render_quiz_cards``), the push path
(``push_to_canvas`` quiz-question create/update), and preflight checks
(``preflight_checks.check_identical_feedback``) ALSO accept the ``_html``
suffixed variants (``correct_comments_html`` etc.) as a fallback for
resilience, but writers (mockup/quiz generators) should prefer the plain
names to match the canonical convention.
"""

from __future__ import annotations

import argparse
import json
import os
from dataclasses import asdict, dataclass, field
from pathlib import Path
from typing import Any, Literal, NotRequired, TypedDict

from canvas_api import get_plugin_root


ObjectType = Literal["pages", "assignments", "discussions", "quizzes", "rubrics"]

TYPE_ALIASES = {
    "page": "pages",
    "pages": "pages",
    "assignment": "assignments",
    "assignments": "assignments",
    "discussion": "discussions",
    "discussions": "discussions",
    "quiz": "quizzes",
    "quizzes": "quizzes",
    "rubric": "rubrics",
    "rubrics": "rubrics",
}

EXTENSIONS = {
    "pages": ".html",
    "assignments": ".html",
    "discussions": ".html",
    "quizzes": ".json",
    "rubrics": ".html",
}

QUESTION_TYPE_ALIASES = {
    "mc": "multiple_choice_question",
    "ma": "multiple_answers_question",
    "tf": "true_false_question",
    "short_answer": "short_answer_question",
    "numerical": "numerical_question",
    "fill_in_multiple_blanks": "fill_in_multiple_blanks_question",
    "matching": "matching_question",
    "ordering": "ordering_question",
}

KNOWN_QUESTION_TYPES = {
    "multiple_choice_question",
    "multiple_answers_question",
    "true_false_question",
    "short_answer_question",
    "numerical_question",
    "fill_in_multiple_blanks_question",
    "multiple_dropdowns_question",
    "matching_question",
    "ordering_question",
    "calculated_question",
    "essay_question",
    "file_upload_question",
    "text_only_question",
}


class StagedMeta(TypedDict):
    module_number: int
    title: str
    source_canvas_id: NotRequired[int | str]
    provisional_id: NotRequired[str]
    destructive_reconcile_ok: NotRequired[bool]


class QuizSettings(TypedDict):
    title: str
    quiz_type: str
    allowed_attempts: int
    scoring_policy: NotRequired[str]
    time_limit: NotRequired[int | None]
    shuffle_answers: NotRequired[bool]
    published: NotRequired[bool]
    canvas_quiz_id: NotRequired[int | str]


class QuizAnswer(TypedDict, total=False):
    id: int | str
    answer_text: str
    answer_weight: int | float
    answer_comments: str
    answer_comment_html: str
    answer_comments_html: str
    answer_match_left: str
    answer_match_right: str
    blank_id: str
    numerical_answer_type: str
    exact: int | float
    margin: int | float
    approximate: int | float
    start: int | float
    end: int | float
    answer_order: int


class QuizQuestion(TypedDict, total=False):
    id: int | str
    question_name: str
    question_type: str
    points_possible: int | float
    question_text: str
    correct_comments: str
    incorrect_comments: str
    neutral_comments: str
    text_after_answers: str
    answers: list[QuizAnswer]
    matching_answer_incorrect_matches: list[str]


class StagedQuiz(TypedDict, total=False):
    quiz: QuizSettings
    questions: list[QuizQuestion]


class StagedPage(TypedDict, total=False):
    html: str
    meta: StagedMeta


class StagedAssignment(TypedDict, total=False):
    html: str
    meta: StagedMeta


class StagedDiscussion(TypedDict, total=False):
    html: str
    meta: StagedMeta


@dataclass
class SchemaIssue:
    code: str
    message: str
    location: str = ""


@dataclass
class ValidationResult:
    ok: bool
    errors: list[SchemaIssue] = field(default_factory=list)
    warnings: list[SchemaIssue] = field(default_factory=list)
    course_id: str = ""
    object_type: str = ""
    object_id: str = ""
    path: str = ""

    def to_dict(self) -> dict[str, Any]:
        data = asdict(self)
        data["errors"] = [asdict(issue) for issue in self.errors]
        data["warnings"] = [asdict(issue) for issue in self.warnings]
        return data


def _normalize_object_type(object_type: str) -> ObjectType:
    normalized = TYPE_ALIASES.get(str(object_type or ""))
    if normalized not in EXTENSIONS:
        raise ValueError(f"Unsupported staged object type: {object_type}")
    return normalized  # type: ignore[return-value]


def _staged_path(course_id: str, object_type: str, object_id: str) -> Path:
    normalized = _normalize_object_type(object_type)
    return (
        Path(get_plugin_root())
        / "courses"
        / str(course_id)
        / "staging"
        / normalized
        / f"{object_id}{EXTENSIONS[normalized]}"
    )


def _read_meta(main_path: Path) -> dict[str, Any]:
    meta_path = Path(str(main_path) + ".meta.json")
    if not meta_path.exists():
        return {}
    try:
        data = json.loads(meta_path.read_text(encoding="utf-8"))
    except (json.JSONDecodeError, OSError):
        return {"__invalid__": True}
    return data if isinstance(data, dict) else {"__invalid__": True}


def _add_error(errors: list[SchemaIssue], code: str, message: str, location: str = "") -> None:
    errors.append(SchemaIssue(code=code, message=message, location=location))


def _add_warning(warnings: list[SchemaIssue], code: str, message: str, location: str = "") -> None:
    warnings.append(SchemaIssue(code=code, message=message, location=location))


def _question_type(raw: Any) -> str:
    value = str(raw or "").strip()
    return QUESTION_TYPE_ALIASES.get(value, value)


def _validate_meta(meta: dict[str, Any], warnings: list[SchemaIssue]) -> None:
    if not meta:
        _add_warning(warnings, "missing_meta", "Missing .meta.json sidecar.", ".meta.json")
        return
    if meta.get("__invalid__"):
        _add_warning(warnings, "invalid_meta", ".meta.json sidecar is not a JSON object.", ".meta.json")
        return
    if "module_number" not in meta:
        _add_warning(warnings, "missing_module_number", "Missing optional module_number in .meta.json.", ".meta.json")
    elif not isinstance(meta.get("module_number"), int):
        _add_warning(warnings, "invalid_module_number", "module_number should be an integer.", ".meta.json.module_number")
    if not meta.get("title"):
        _add_warning(warnings, "missing_title", "Missing optional title in .meta.json.", ".meta.json.title")
    if "destructive_reconcile_ok" in meta and not isinstance(meta.get("destructive_reconcile_ok"), bool):
        _add_warning(
            warnings,
            "invalid_destructive_reconcile_ok",
            "destructive_reconcile_ok should be boolean when present.",
            ".meta.json.destructive_reconcile_ok",
        )


def _validate_html_payload(payload: Any, object_type: str, errors: list[SchemaIssue]) -> None:
    if not isinstance(payload, str):
        _add_error(errors, "invalid_payload_type", f"{object_type} staged payload must be HTML text.")
        return
    if not payload.strip():
        _add_error(errors, "empty_payload", f"{object_type} staged payload is empty.")


def _validate_answer_shape(
    question_type: str,
    answer: dict[str, Any],
    index: int,
    errors: list[SchemaIssue],
) -> None:
    loc = f"questions[].answers[{index}]"
    if question_type in {"multiple_choice_question", "multiple_answers_question", "true_false_question", "short_answer_question"}:
        if not answer.get("answer_text"):
            _add_error(errors, "missing_answer_text", "Answer is missing answer_text.", loc)
        if question_type != "short_answer_question" and "answer_weight" not in answer:
            _add_error(errors, "missing_answer_weight", "Answer is missing answer_weight.", loc)
    elif question_type == "numerical_question":
        if not (
            answer.get("numerical_answer_type")
            or "exact" in answer
            or "approximate" in answer
            or ("start" in answer and "end" in answer)
            or answer.get("answer_text")
        ):
            _add_error(
                errors,
                "invalid_numerical_answer",
                "Numerical answer needs numerical_answer_type, exact/approximate/start-end, or answer_text.",
                loc,
            )
    elif question_type == "fill_in_multiple_blanks_question":
        if not answer.get("blank_id"):
            _add_error(errors, "missing_blank_id", "Fill-in-multiple-blanks answer is missing blank_id.", loc)
        if not answer.get("answer_text"):
            _add_error(errors, "missing_answer_text", "Fill-in-multiple-blanks answer is missing answer_text.", loc)
    elif question_type == "matching_question":
        if not answer.get("answer_match_left"):
            _add_error(errors, "missing_match_left", "Matching answer is missing answer_match_left.", loc)
        if not answer.get("answer_match_right"):
            _add_error(errors, "missing_match_right", "Matching answer is missing answer_match_right.", loc)
    elif question_type == "ordering_question":
        if not answer.get("answer_text"):
            _add_error(errors, "missing_answer_text", "Ordering answer is missing answer_text.", loc)


def _validate_quiz(payload: Any, meta: dict[str, Any], errors: list[SchemaIssue], warnings: list[SchemaIssue]) -> None:
    if not isinstance(payload, dict):
        _add_error(errors, "invalid_quiz_payload", "Quiz staged payload must be a JSON object.")
        return
    quiz = payload.get("quiz")
    if not isinstance(quiz, dict):
        _add_error(errors, "missing_quiz", "Quiz staged payload requires a quiz object.", "quiz")
    elif not quiz.get("title"):
        _add_error(errors, "missing_quiz_title", "Quiz settings are missing title.", "quiz.title")

    if "questions" not in payload:
        _add_warning(
            warnings,
            "settings_only_quiz",
            "Quiz has no questions key; push will update settings only and skip question reconciliation.",
            "questions",
        )
        return

    questions = payload.get("questions")
    if not isinstance(questions, list):
        _add_error(errors, "invalid_questions", "Quiz questions must be a list.", "questions")
        return
    missing_ids = 0
    for q_index, question in enumerate(questions):
        q_loc = f"questions[{q_index}]"
        if not isinstance(question, dict):
            _add_error(errors, "invalid_question", "Question must be a JSON object.", q_loc)
            continue
        q_type = _question_type(question.get("question_type"))
        if q_type not in KNOWN_QUESTION_TYPES:
            _add_error(errors, "unknown_question_type", f"Unknown question_type: {question.get('question_type')}", f"{q_loc}.question_type")
            continue
        if "id" not in question:
            missing_ids += 1
        if not question.get("question_name"):
            _add_error(errors, "missing_question_name", "Question is missing question_name.", f"{q_loc}.question_name")
        if not question.get("question_text"):
            _add_error(errors, "missing_question_text", "Question is missing question_text.", f"{q_loc}.question_text")
        if "points_possible" not in question:
            _add_error(errors, "missing_points_possible", "Question is missing points_possible.", f"{q_loc}.points_possible")
        answers = question.get("answers", [])
        if q_type not in {"essay_question", "file_upload_question"} and not isinstance(answers, list):
            _add_error(errors, "invalid_answers", "Question answers must be a list.", f"{q_loc}.answers")
            continue
        if q_type in KNOWN_QUESTION_TYPES and not answers:
            _add_error(errors, "missing_answers", "Question is missing answers.", f"{q_loc}.answers")
        for a_index, answer in enumerate(answers or []):
            if not isinstance(answer, dict):
                _add_error(errors, "invalid_answer", "Answer must be a JSON object.", f"{q_loc}.answers[{a_index}]")
                continue
            _validate_answer_shape(q_type, answer, a_index, errors)
    if missing_ids:
        _add_warning(
            warnings,
            "quiz_questions_missing_ids",
            (
                f"{missing_ids} staged quiz question(s) have no Canvas id. "
                "Existing Canvas questions not represented by staged IDs may be deleted during reconciliation."
            ),
            "questions",
        )
    if questions and not meta.get("destructive_reconcile_ok") and missing_ids == len(questions):
        _add_warning(
            warnings,
            "destructive_reconcile_not_declared",
            "All staged quiz questions are missing ids and destructive_reconcile_ok is not true in .meta.json.",
            ".meta.json.destructive_reconcile_ok",
        )


# ── QuizDetailsSchema ───────────────────────────────────────────────────────
#
# Strict subset of the full quiz settings schema used by the per-quiz Details
# tab in the staging editor. Any unknown key is a hard validation error so
# typos surface immediately rather than silently writing through to Canvas.

QUIZ_DETAILS_ALLOWED_KEYS = {
    "title",
    "quiz_type",
    "points_possible",
    "allowed_attempts",
    "time_limit",
    "scoring_policy",
    "shuffle_answers",
    "published",
}

QUIZ_DETAILS_REQUIRED_KEYS = {
    "title",
    "quiz_type",
    "allowed_attempts",
    "scoring_policy",
    "shuffle_answers",
    "published",
}

QUIZ_TYPE_VALUES = {"practice_quiz", "assignment"}
SCORING_POLICY_VALUES = {"keep_highest", "keep_latest", "take_average"}


def validate_quiz_details(payload: Any) -> ValidationResult:
    """Validate a QuizDetailsSchema payload (strict).

    Rejects unknown keys. Returns a ValidationResult with all errors collected.
    """
    errors: list[SchemaIssue] = []
    warnings: list[SchemaIssue] = []

    if not isinstance(payload, dict):
        _add_error(errors, "invalid_payload", "Quiz details payload must be a JSON object.")
        return ValidationResult(ok=False, errors=errors, warnings=warnings)

    # Unknown keys → hard error (catch typos)
    unknown = set(payload.keys()) - QUIZ_DETAILS_ALLOWED_KEYS
    if unknown:
        for key in sorted(unknown):
            _add_error(errors, "unknown_key", f"Unknown field in quiz details: {key}", key)

    # Required keys present
    missing = QUIZ_DETAILS_REQUIRED_KEYS - set(payload.keys())
    for key in sorted(missing):
        _add_error(errors, "missing_field", f"Required field missing: {key}", key)

    # title
    if "title" in payload:
        title = payload["title"]
        if not isinstance(title, str) or not title.strip():
            _add_error(errors, "invalid_title", "title must be a non-empty string.", "title")
        elif len(title) > 255:
            _add_error(errors, "invalid_title", "title must be 1-255 characters.", "title")

    # quiz_type
    if "quiz_type" in payload:
        qt = payload["quiz_type"]
        if qt not in QUIZ_TYPE_VALUES:
            _add_error(
                errors,
                "invalid_quiz_type",
                f"quiz_type must be one of {sorted(QUIZ_TYPE_VALUES)}.",
                "quiz_type",
            )

    # points_possible (optional)
    if "points_possible" in payload:
        pp = payload["points_possible"]
        if isinstance(pp, bool) or not isinstance(pp, int) or pp < 0:
            _add_error(
                errors,
                "invalid_points_possible",
                "points_possible must be an integer >= 0.",
                "points_possible",
            )

    # allowed_attempts
    if "allowed_attempts" in payload:
        aa = payload["allowed_attempts"]
        if isinstance(aa, bool) or not isinstance(aa, int):
            _add_error(errors, "invalid_allowed_attempts", "allowed_attempts must be an integer.", "allowed_attempts")
        elif aa != -1 and aa < 1:
            _add_error(
                errors,
                "invalid_allowed_attempts",
                "allowed_attempts must be -1 (unlimited) or >= 1.",
                "allowed_attempts",
            )

    # time_limit (optional)
    if "time_limit" in payload:
        tl = payload["time_limit"]
        if tl is not None:
            if isinstance(tl, bool) or not isinstance(tl, int) or tl < 1:
                _add_error(
                    errors,
                    "invalid_time_limit",
                    "time_limit must be a positive integer or null.",
                    "time_limit",
                )

    # scoring_policy
    if "scoring_policy" in payload:
        sp = payload["scoring_policy"]
        if sp not in SCORING_POLICY_VALUES:
            _add_error(
                errors,
                "invalid_scoring_policy",
                f"scoring_policy must be one of {sorted(SCORING_POLICY_VALUES)}.",
                "scoring_policy",
            )

    # shuffle_answers
    if "shuffle_answers" in payload:
        if not isinstance(payload["shuffle_answers"], bool):
            _add_error(errors, "invalid_shuffle_answers", "shuffle_answers must be a boolean.", "shuffle_answers")

    # published
    if "published" in payload:
        if not isinstance(payload["published"], bool):
            _add_error(errors, "invalid_published", "published must be a boolean.", "published")

    return ValidationResult(ok=not errors, errors=errors, warnings=warnings)


def validate_payload(
    payload: Any,
    *,
    course_id: str,
    object_type: str,
    object_id: str,
    meta: dict[str, Any] | None = None,
    path: str = "",
) -> ValidationResult:
    normalized = _normalize_object_type(object_type)
    errors: list[SchemaIssue] = []
    warnings: list[SchemaIssue] = []
    meta = meta or {}
    _validate_meta(meta, warnings)
    if normalized == "quizzes":
        _validate_quiz(payload, meta, errors, warnings)
    else:
        _validate_html_payload(payload, normalized, errors)
    return ValidationResult(
        ok=not errors,
        errors=errors,
        warnings=warnings,
        course_id=str(course_id),
        object_type=normalized,
        object_id=str(object_id),
        path=str(path),
    )


def validate(course_id: str, object_type: str, object_id: str) -> dict[str, Any]:
    """Validate a staged artifact by course/type/id.

    Returns a JSON-serializable dict:
      {ok: bool, errors: [{code, message, location}], warnings: [...]}
    """
    normalized = _normalize_object_type(object_type)
    path = _staged_path(str(course_id), normalized, str(object_id))
    return validate_file(path, course_id=str(course_id), object_type=normalized, object_id=str(object_id))


def validate_file(
    path: str | Path,
    *,
    course_id: str,
    object_type: str,
    object_id: str,
) -> dict[str, Any]:
    """Validate a concrete staged file path.

    Callers that monkeypatch or redirect the plugin root should use this with
    their already-resolved path instead of recomputing from get_plugin_root().
    """
    normalized = _normalize_object_type(object_type)
    path = Path(path)
    errors: list[SchemaIssue] = []
    warnings: list[SchemaIssue] = []
    if not path.exists():
        _add_error(errors, "missing_staged_file", f"Staged file not found: {path}")
        return ValidationResult(
            ok=False,
            errors=errors,
            warnings=warnings,
            course_id=str(course_id),
            object_type=normalized,
            object_id=str(object_id),
            path=str(path),
        ).to_dict()

    try:
        if normalized == "quizzes":
            payload = json.loads(path.read_text(encoding="utf-8"))
        else:
            payload = path.read_text(encoding="utf-8")
    except json.JSONDecodeError as exc:
        _add_error(errors, "invalid_json", f"Staged JSON is malformed: {exc}", str(path))
        return ValidationResult(
            ok=False,
            errors=errors,
            warnings=warnings,
            course_id=str(course_id),
            object_type=normalized,
            object_id=str(object_id),
            path=str(path),
        ).to_dict()
    except OSError as exc:
        _add_error(errors, "unreadable_staged_file", f"Could not read staged file: {exc}", str(path))
        return ValidationResult(
            ok=False,
            errors=errors,
            warnings=warnings,
            course_id=str(course_id),
            object_type=normalized,
            object_id=str(object_id),
            path=str(path),
        ).to_dict()

    result = validate_payload(
        payload,
        course_id=str(course_id),
        object_type=normalized,
        object_id=str(object_id),
        meta=_read_meta(path),
        path=str(path),
    )
    return result.to_dict()


def scan_staging_tree(root: Path | None = None) -> dict[str, Any]:
    root = root or (Path(get_plugin_root()) / "courses")
    results = []
    if not root.exists():
        return {"ok": True, "results": [], "errors": 0, "warnings": 0}
    for course_dir in sorted(p for p in root.iterdir() if p.is_dir()):
        staging = course_dir / "staging"
        if not staging.exists():
            continue
        for object_type, ext in EXTENSIONS.items():
            type_dir = staging / object_type
            if not type_dir.exists():
                continue
            for path in sorted(type_dir.rglob(f"*{ext}")):
                name = path.name
                if any(suffix in name for suffix in (".issues.json", ".vision.json", ".meta.json")):
                    continue
                object_id = path.stem
                result = validate(course_dir.name, object_type, object_id)
                if result["errors"] or result["warnings"]:
                    results.append(result)
    error_count = sum(len(item["errors"]) for item in results)
    warning_count = sum(len(item["warnings"]) for item in results)
    return {
        "ok": error_count == 0,
        "results": results,
        "errors": error_count,
        "warnings": warning_count,
    }


def main(argv: list[str] | None = None) -> int:
    parser = argparse.ArgumentParser(description="Validate ASUCB staged content schemas.")
    parser.add_argument("--course-id", help="Canvas course ID.")
    parser.add_argument("--type", choices=sorted(TYPE_ALIASES), help="Staged object type.")
    parser.add_argument("--id", help="Staged object id.")
    parser.add_argument("--scan-all", action="store_true", help="Validate all courses/*/staging artifacts.")
    args = parser.parse_args(argv)

    if args.scan_all:
        result = scan_staging_tree()
    else:
        if not args.course_id or not args.type or not args.id:
            parser.error("Provide --course-id, --type, and --id, or use --scan-all")
        result = validate(args.course_id, args.type, args.id)
    print(json.dumps(result, indent=2, ensure_ascii=False))
    return 0 if result.get("ok") else 1


if __name__ == "__main__":
    raise SystemExit(main())
