"""Course plan generation pipeline.

Two public entry points:

* :py:meth:`generate_plan` — given a short brief (course title, CEFR level,
  duration, skill coverage, grammar focus, resources), produce a full
  curriculum outline and persist it as an
  :py:class:`encoach.course.plan` record, with one
  :py:class:`encoach.course.plan.week` row per planned week.

* :py:meth:`generate_week_materials` — given an existing plan and a
  week number, produce the actual teaching content for that week
  (reading text, listening script, speaking prompts, grammar mini-lesson
  + practice, writing prompt, vocabulary list) and persist each as an
  :py:class:`encoach.course.plan.material` row.

We deliberately ask the LLM to return strict JSON and then normalise it
server-side — the frontend gets a stable shape no matter how loose the
model's output is. Any parse failure is swallowed and reported back
through the standard error channel so the caller can retry without the
server crashing.
"""

import json
import logging

try:
    from odoo.addons.encoach_ai.services.openai_service import OpenAIService
except ImportError:
    OpenAIService = None

# Markers that indicate the OpenAI account is unusable until the operator
# fixes billing / keys / quota — i.e. retrying right now will never help.
# We mirror the OpenAI service's own non-retryable list so a 429 caused by
# `insufficient_quota` triggers the free fallback instead of bubbling up
# as a 500 from the wizard's "Finish" button.
_AI_PERMANENT_FAILURE_MARKERS = (
    "insufficient_quota",
    "invalid_api_key",
    "incorrect_api_key",
    "account_deactivated",
    "billing_hard_limit_reached",
    "openai not configured",
    "ai is disabled",
)

# AgentRuntime is the LangGraph-backed engine. When the feature flag
# ``encoach_ai.use_langgraph_runtime`` is true (default) and an agent with
# the matching key is configured, the pipeline routes through the agent
# instead of calling OpenAIService directly. This keeps the existing
# fall-back path so the pipeline still works if the agent layer is broken
# or being upgraded.
try:
    from odoo.addons.encoach_ai.services.agent_runtime import AgentRuntime
except ImportError:  # pragma: no cover - optional dep
    AgentRuntime = None

_logger = logging.getLogger(__name__)


def _is_permanent_ai_failure(message):
    """Return True iff the AI error indicates a non-transient condition.

    These are operator-fixable problems (out of credit, wrong key, AI
    feature switched off) where retrying would just hang the wizard. In
    those cases the pipeline degrades to a deterministic skeleton plan
    so the user still gets a usable record.
    """
    if not message:
        return False
    low = str(message).lower()
    return any(m in low for m in _AI_PERMANENT_FAILURE_MARKERS)


# JSON schema we coax the LLM into following. Keeping this as a prompt
# string (rather than an OpenAI function call) makes it portable if the
# underlying `chat_json` implementation ever changes providers.
_PLAN_JSON_HINT = """
Return JSON with exactly this shape:
{
  "description": "<2-4 sentence course description incl. CEFR>",
  "objectives": ["<overall course objective>", ...],
  "outcomes": {
    "reading":     [{"code": "RLO1", "description": "..."}, ...],
    "writing":     [{"code": "WLO1", "description": "..."}, ...],
    "listening":   [{"code": "LLO1", "description": "..."}, ...],
    "speaking":    [{"code": "SLO1", "description": "..."}, ...],
    "vocabulary":  [{"code": "VLO1", "description": "..."}, ...],
    "grammar":     [{"code": "GLO1", "description": "..."}, ...]
  },
  "grammar": [
    {"code": "GT1", "label": "Present tense",
     "sub_items": ["present simple", "present continuous"]},
    ...
  ],
  "assessment": {
    "continuous_assessment": {"total_weight": 50, "components":
        [{"name":"MTE","weight":30}, {"name":"Oral Presentation","weight":10}, ...]},
    "final_exam":            {"total_weight": 50}
  },
  "resources": [
    {"type": "textbook", "citation": "..."},
    {"type": "stm",      "citation": "..."}
  ],
  "weeks": [
    {
      "week_number": 1,
      "date_label": "7-11 Sep. 2025",
      "unit": "One",
      "focus": "Personal introductions, simple present",
      "items": [
        {"skill": "reading",   "outcome_codes": ["RLO1","RLO2"], "remarks": "..."},
        {"skill": "writing",   "outcome_codes": ["WLO1","WLO2"], "remarks": "..."},
        {"skill": "listening", "outcome_codes": ["LLO1"],        "remarks": ""},
        {"skill": "speaking",  "outcome_codes": ["SLO1","SLO2"], "remarks": ""},
        {"skill": "grammar",   "outcome_codes": ["GLO1"],        "remarks": ""}
      ]
    },
    ...
  ]
}
Use the exact outcome codes across `outcomes` and `weeks[*].items[*].outcome_codes`.
"""


_WEEK_JSON_HINT = """
Return JSON with exactly this shape:
{
  "materials": [
    {
      "skill": "reading",
      "material_type": "reading_text",
      "title": "...",
      "summary": "1-2 sentence teacher note",
      "body": {
        "text": "<reading passage ~350-450 words>",
        "questions": [
          {"q": "...", "type": "multiple_choice",
           "options": ["A","B","C","D"], "answer": "A"}
        ]
      }
    },
    {
      "skill": "listening",
      "material_type": "listening_script",
      "title": "...",
      "summary": "...",
      "body": {
        "script": "<3-4 minute dialogue or monologue>",
        "comprehension_questions": [
          {"q": "...", "answer": "..."}
        ]
      }
    },
    {
      "skill": "speaking",
      "material_type": "speaking_prompt",
      "title": "...",
      "summary": "...",
      "body": {
        "prompts": ["...", "..."],
        "useful_language": ["..."]
      }
    },
    {
      "skill": "writing",
      "material_type": "writing_prompt",
      "title": "...",
      "summary": "...",
      "body": {
        "prompt": "...",
        "word_count": 150,
        "model_paragraph": "..."
      }
    },
    {
      "skill": "grammar",
      "material_type": "grammar_lesson",
      "title": "...",
      "summary": "...",
      "body": {
        "explanation": "...",
        "examples": ["...","..."],
        "practice": [
          {"q":"...", "answer":"..."}
        ]
      }
    },
    {
      "skill": "vocabulary",
      "material_type": "vocabulary_list",
      "title": "...",
      "summary": "...",
      "body": {
        "words": [
          {"term":"...", "pos":"n.", "definition":"...", "example":"..."}
        ]
      }
    }
  ]
}
Only include skills present in the week's items list.
"""


class CoursePlanPipeline:
    """Wrap the LLM call, normalise the JSON, persist the result."""

    def __init__(self, env, *, language="en"):
        self.env = env
        self.language = language
        if OpenAIService is None:
            raise RuntimeError(
                "OpenAIService is not available — encoach_ai is not installed."
            )
        self.ai = OpenAIService(env, language=language)
        # Decide once per instance whether to route through the LangGraph
        # AgentRuntime or fall back to the direct chat_json path.
        self._use_agent = self._resolve_agent_flag(env)

    @staticmethod
    def _resolve_agent_flag(env):
        if AgentRuntime is None:
            return False
        try:
            raw = env["ir.config_parameter"].sudo().get_param(
                "encoach_ai.use_langgraph_runtime", "True",
            )
        except Exception:
            return False
        return str(raw).strip().lower() in ("1", "true", "yes", "on")

    def _agent(self, key):
        """Lazily build an AgentRuntime for ``key`` if the flag allows it."""
        if not self._use_agent or AgentRuntime is None:
            return None
        try:
            return AgentRuntime.from_key(self.env, key, language=self.language)
        except Exception:
            _logger.exception("AgentRuntime.from_key(%s) failed", key)
            return None

    # ------------------------------------------------------------------
    # Plan-level generation
    # ------------------------------------------------------------------
    def generate_plan(self, brief):
        """Generate the full course plan header + week rows from a brief.

        :param brief: ``dict`` with optional keys:
            title, cefr_level, total_weeks, contact_hours_per_week,
            skills_division, grammar_focus (list), resources (list),
            learner_profile (string), notes (string), course_id (int),
            language (string ISO-639-1).
        :returns: ``encoach.course.plan`` record.
        """
        title = (brief.get('title') or '').strip() or 'Untitled course'
        cefr = (brief.get('cefr_level') or 'a2').lower()
        total_weeks = int(brief.get('total_weeks') or 12)
        contact_hours = int(brief.get('contact_hours_per_week') or 18)
        skills_division = (brief.get('skills_division') or '').strip()
        grammar_focus = brief.get('grammar_focus') or []
        resources = brief.get('resources') or []
        learner_profile = (brief.get('learner_profile') or '').strip()
        notes = (brief.get('notes') or '').strip()

        system_msg = (
            "You are an expert English language curriculum designer. "
            "You produce structured course outlines suitable for a "
            "general foundation programme. You MUST return valid JSON "
            "that matches the schema in the user prompt exactly. Never "
            "wrap the JSON in prose."
        )
        user_msg = (
            f"Design a {total_weeks}-week course titled \"{title}\" at "
            f"CEFR {cefr.upper()} with approximately {contact_hours} "
            f"contact hours per week.\n"
            f"Skills division: {skills_division or 'auto'}.\n"
            f"Grammar focus: {', '.join(grammar_focus) or 'auto'}.\n"
            f"Resources to reference: "
            f"{'; '.join(resources) if resources else 'none'}.\n"
            f"Learner profile: {learner_profile or 'mixed L1 adult learners'}.\n"
            f"Additional notes: {notes or 'none'}.\n\n"
            + _PLAN_JSON_HINT
        )

        # Prefer the LangGraph agent if one is configured; fall back to the
        # direct OpenAI call so the feature still works if the agent table
        # is empty or the runtime fails to compile.
        content = self._invoke_agent_or_chat(
            agent_key="course_planner",
            system_msg=system_msg,
            user_msg=user_msg,
            variables={
                "title": title,
                "cefr_level": cefr,
                "total_weeks": total_weeks,
            },
            temperature=0.4,
            max_tokens=4096,
            action="course_plan.generate",
        )

        # If the LLM is unavailable (quota exhausted, missing key,
        # disabled, network error) we degrade to a deterministic stub
        # plan instead of raising. The wizard's "Finish" button needs to
        # always succeed in producing a record the user can open and
        # iterate on; a hard failure here leaves the frontend stuck on a
        # 5+ minute spinner while the OpenAI client retries.
        used_fallback = False
        ai_error = None
        if content is None or 'error' in content:
            ai_error = (content or {}).get('error', 'AI generation failed.')
            if _is_permanent_ai_failure(ai_error):
                _logger.warning(
                    "Course plan AI unavailable (%s); using free fallback",
                    ai_error,
                )
                content = self._build_fallback_plan_content(
                    title=title,
                    cefr=cefr,
                    total_weeks=total_weeks,
                    contact_hours=contact_hours,
                    skills_division=skills_division,
                    grammar_focus=grammar_focus,
                    resources=resources,
                    learner_profile=learner_profile,
                )
                used_fallback = True
            else:
                # Genuine transient failure — surface it to the caller so
                # they can retry. The frontend toasts the error message.
                raise RuntimeError(ai_error)

        description = (content.get('description') or '').strip()
        if used_fallback:
            description = (
                description
                + "\n\n[Auto-generated skeleton — OpenAI unavailable. "
                "Update the AI provider settings or restore billing, "
                "then click Regenerate.]"
            ).strip()

        plan_vals = {
            'name': title,
            'cefr_level': cefr if cefr in {
                'pre_a1', 'a1', 'a2', 'b1', 'b2', 'c1', 'c2'
            } else 'a2',
            'total_weeks': total_weeks,
            'contact_hours_per_week': contact_hours,
            'skills_division': skills_division,
            'description': description,
            'objectives_json': json.dumps(content.get('objectives') or [], ensure_ascii=False),
            'outcomes_json': json.dumps(content.get('outcomes') or {}, ensure_ascii=False),
            'grammar_json': json.dumps(content.get('grammar') or [], ensure_ascii=False),
            'assessment_json': json.dumps(content.get('assessment') or {}, ensure_ascii=False),
            'resources_json': json.dumps(content.get('resources') or [], ensure_ascii=False),
            'brief_json': json.dumps(brief, ensure_ascii=False),
            'status': 'generated' if not used_fallback else 'draft',
        }
        if brief.get('course_id'):
            try:
                plan_vals['course_id'] = int(brief['course_id'])
            except (TypeError, ValueError):
                pass

        plan = self.env['encoach.course.plan'].sudo().create(plan_vals)

        # Create week rows.
        Week = self.env['encoach.course.plan.week'].sudo()
        for w in content.get('weeks') or []:
            try:
                Week.create({
                    'plan_id': plan.id,
                    'week_number': int(w.get('week_number') or 0),
                    'date_label': (w.get('date_label') or '').strip(),
                    'unit': (w.get('unit') or '').strip(),
                    'focus': (w.get('focus') or '').strip(),
                    'items_json': json.dumps(w.get('items') or [], ensure_ascii=False),
                })
            except Exception as exc:  # pragma: no cover - defensive
                _logger.warning("Skipping bad week row: %s", exc)
        return plan

    # ------------------------------------------------------------------
    # Week-level material generation
    # ------------------------------------------------------------------
    def generate_week_materials(self, plan_id, week_number):
        """Generate teaching materials for one week and persist them.

        Any existing materials for the same plan_id + week_number are
        replaced — callers that want to keep old versions should copy
        them before re-running.
        """
        plan = self.env['encoach.course.plan'].sudo().browse(int(plan_id))
        if not plan.exists():
            raise ValueError('Plan not found')
        week = plan.week_ids.filtered(lambda w: w.week_number == int(week_number))
        if not week:
            raise ValueError(f'Week {week_number} not found on plan {plan_id}')
        week = week[0]

        outcomes = plan._loads(plan.outcomes_json, {})
        items = week._loads(week.items_json, [])

        system_msg = (
            "You are an expert English language teacher creating ready-"
            "to-use classroom materials. Your output MUST be valid JSON "
            "matching the schema in the user prompt. Keep reading texts "
            "close to the target word count for the CEFR level. Keep "
            "listening scripts natural and conversational. All tasks "
            "must target the outcome codes supplied."
        )
        user_msg = (
            f"Course: {plan.name}\n"
            f"CEFR: {(plan.cefr_level or '').upper()}\n"
            f"Week {week.week_number} — {week.date_label or ''}\n"
            f"Unit: {week.unit or ''}\n"
            f"Focus: {week.focus or ''}\n\n"
            f"Week items:\n{json.dumps(items, indent=2, ensure_ascii=False)}\n\n"
            f"Full outcome catalogue (for looking up codes):\n"
            f"{json.dumps(outcomes, indent=2, ensure_ascii=False)}\n\n"
            + _WEEK_JSON_HINT
        )

        content = self._invoke_agent_or_chat(
            agent_key="course_week_materials",
            system_msg=system_msg,
            user_msg=user_msg,
            variables={
                "course": plan.name,
                "cefr_level": (plan.cefr_level or "").lower(),
                "week_number": week.week_number,
            },
            temperature=0.6,
            max_tokens=6000,
            action="course_plan.generate_week",
        )
        used_week_fallback = False
        if content is None or 'error' in content:
            ai_error = (content or {}).get('error', 'AI generation failed.')
            if _is_permanent_ai_failure(ai_error):
                _logger.warning(
                    "Week materials AI unavailable (%s); using free fallback",
                    ai_error,
                )
                content = self._build_fallback_week_content(
                    plan_name=plan.name,
                    cefr=(plan.cefr_level or "").lower(),
                    week_number=week.week_number,
                    items=items,
                )
                used_week_fallback = True
            else:
                raise RuntimeError(ai_error)

        # Wipe any previous materials for this week so re-generating is
        # idempotent and we never accumulate duplicates.
        existing = self.env['encoach.course.plan.material'].sudo().search([
            ('plan_id', '=', plan.id), ('week_id', '=', week.id),
        ])
        if existing:
            # Keep instructor-curated static rows; only replace generated ones.
            existing.filtered(lambda m: not m.is_static).unlink()

        Material = self.env['encoach.course.plan.material'].sudo()
        created = []
        skeleton_note = (
            "[Auto-generated skeleton — OpenAI unavailable. "
            "Update the AI provider settings or restore billing, "
            "then regenerate this week's materials.]"
        )
        for m in content.get('materials') or []:
            try:
                summary = (m.get('summary') or '').strip()
                if used_week_fallback:
                    summary = (summary + "\n\n" + skeleton_note).strip()
                rec = Material.create({
                    'plan_id': plan.id,
                    'week_id': week.id,
                    'skill': (m.get('skill') or 'integrated').strip().lower(),
                    'material_type': (m.get('material_type') or 'other').strip(),
                    'title': (m.get('title') or '').strip() or 'Untitled',
                    'summary': summary,
                    'body_json': json.dumps(m.get('body') or {}, ensure_ascii=False),
                    'body_text': self._flatten_body(m.get('body') or {}),
                })
                created.append(rec)
            except Exception as exc:  # pragma: no cover - defensive
                _logger.warning("Skipping bad material row: %s", exc)
        return created

    # ------------------------------------------------------------------
    # Internals
    # ------------------------------------------------------------------
    def _chat_json(self, messages, **kwargs):
        """Best-effort wrapper around ``ai.chat_json``.

        The underlying service may raise (network, invalid key, etc.),
        or return a dict with an ``error`` field when content moderation
        rejects the request. We normalise both to a dict so callers can
        just check ``'error' in result``.
        """
        try:
            return self.ai.chat_json(messages, **kwargs)
        except Exception as exc:
            _logger.exception("Course plan AI call failed")
            return {'error': str(exc)}

    def _invoke_agent_or_chat(self, *, agent_key, system_msg, user_msg,
                                variables, temperature, max_tokens, action):
        """Route through AgentRuntime when available; fall back to chat_json.

        Both branches return the same shape — a dict the caller can
        ``json.loads``-style consume — so the rest of the pipeline doesn't
        change. We pass ``user_msg`` as the payload because the agent's own
        system prompt is normally the one used; only when the agent is
        missing do we pass the inline ``system_msg``.
        """
        runtime = self._agent(agent_key)
        if runtime is not None:
            # The pipeline owns the JSON schema for backward-compat, so we
            # forward the schema-bearing user message into the agent. The
            # agent's stored system prompt covers the role/rules; we add
            # the schema as ``extra_system`` so it's heeded but auditable.
            final = runtime.invoke(
                variables=variables,
                payload=user_msg,
                extra_system=system_msg,
            )
            agent_error = final.get("error")
            if agent_error:
                # Permanent failures (no quota, bad key, AI off) will fail
                # the same way through the legacy chat_json path, so don't
                # double the wait — surface the error and let the caller
                # decide (generate_plan triggers the free fallback).
                if _is_permanent_ai_failure(agent_error):
                    _logger.warning(
                        "agent %s permanent failure (%s); skipping legacy fallback",
                        agent_key, agent_error,
                    )
                    return {"error": agent_error}
                _logger.warning(
                    "agent %s failed (%s); falling back to direct chat_json",
                    agent_key, agent_error,
                )
            else:
                output = final.get("output")
                if isinstance(output, dict):
                    return output
                # Text output — try parsing once, otherwise fall back.
                try:
                    return json.loads(final.get("output_raw") or "{}")
                except Exception:
                    pass
        # Fallback path: plain OpenAI call (legacy).
        return self._chat_json(
            [
                {"role": "system", "content": system_msg},
                {"role": "user", "content": user_msg},
            ],
            temperature=temperature,
            max_tokens=max_tokens,
            action=action,
        )

    # ------------------------------------------------------------------
    # Free fallback — used when OpenAI returns a permanent failure (no
    # quota, bad key, AI disabled). We synthesize a structurally-valid
    # plan so the wizard still completes and the user gets a record they
    # can edit or regenerate. The shape mirrors the JSON schema in
    # _PLAN_JSON_HINT exactly so downstream serializers don't notice.
    # ------------------------------------------------------------------
    @staticmethod
    def _build_fallback_plan_content(
        *, title, cefr, total_weeks, contact_hours, skills_division,
        grammar_focus, resources, learner_profile,
    ):
        cefr_upper = (cefr or "a2").upper()
        skills_text = (skills_division or "").strip() or (
            "Reading & Writing balanced with Listening & Speaking"
        )
        outcomes = {
            "reading":     [{"code": "RLO1", "description": f"Read level-appropriate ({cefr_upper}) texts and identify main ideas."}],
            "writing":     [{"code": "WLO1", "description": "Plan and write short structured paragraphs on familiar topics."}],
            "listening":   [{"code": "LLO1", "description": "Follow short dialogues and monologues at normal speed."}],
            "speaking":    [{"code": "SLO1", "description": "Hold short conversations on personal and study-related topics."}],
            "vocabulary":  [{"code": "VLO1", "description": "Use a level-appropriate active vocabulary across the four skills."}],
            "grammar":     [{"code": "GLO1", "description": "Use the targeted grammar structures accurately in context."}],
        }
        grammar_blocks = [
            {"code": f"GT{i+1}", "label": label.strip() or f"Topic {i+1}",
             "sub_items": []}
            for i, label in enumerate((grammar_focus or [])[:6])
        ] or [
            {"code": "GT1", "label": "Present tenses", "sub_items": ["present simple", "present continuous"]},
            {"code": "GT2", "label": "Past tenses", "sub_items": ["past simple", "past continuous"]},
        ]
        weeks = []
        for w in range(1, max(1, int(total_weeks or 12)) + 1):
            weeks.append({
                "week_number": w,
                "date_label": f"Week {w}",
                "unit": f"Unit {((w - 1) // 2) + 1}",
                "focus": f"Skeleton focus for week {w} — replace via Regenerate.",
                "items": [
                    {"skill": "reading",   "outcome_codes": ["RLO1"], "remarks": ""},
                    {"skill": "writing",   "outcome_codes": ["WLO1"], "remarks": ""},
                    {"skill": "listening", "outcome_codes": ["LLO1"], "remarks": ""},
                    {"skill": "speaking",  "outcome_codes": ["SLO1"], "remarks": ""},
                    {"skill": "grammar",   "outcome_codes": ["GLO1"], "remarks": ""},
                ],
            })
        return {
            "description": (
                f"{cefr_upper} general course over {total_weeks} weeks, "
                f"approximately {contact_hours} contact hours per week. "
                f"Coverage: {skills_text}. "
                f"Profile: {learner_profile or 'mixed adult learners'}."
            ),
            "objectives": [
                f"Develop integrated {cefr_upper}-level skills across reading, writing, listening and speaking.",
                "Build active vocabulary and accurate use of target grammar.",
                "Use language confidently in personal, social and study contexts.",
            ],
            "outcomes": outcomes,
            "grammar": grammar_blocks,
            "assessment": {
                "continuous_assessment": {
                    "total_weight": 50,
                    "components": [
                        {"name": "MTE", "weight": 30},
                        {"name": "Continuous tasks", "weight": 20},
                    ],
                },
                "final_exam": {"total_weight": 50},
            },
            "resources": [
                {"type": "textbook", "citation": (resources[0] if resources else "TBD — replace via Regenerate")},
            ],
            "weeks": weeks,
        }

    # ------------------------------------------------------------------
    # Free fallback for per-week materials. Mirrors the schema in
    # _WEEK_JSON_HINT — placeholder content per skill so the teacher
    # has a starter row they can edit or regenerate later.
    # ------------------------------------------------------------------
    @staticmethod
    def _build_fallback_week_content(*, plan_name, cefr, week_number, items):
        cefr_upper = (cefr or "a2").upper()
        # Only produce materials for the skills that the week's plan
        # actually contains, so the teacher's outline is preserved.
        wanted_skills = []
        seen = set()
        for it in items or []:
            s = (it.get("skill") or "").strip().lower()
            if s and s not in seen:
                wanted_skills.append(s)
                seen.add(s)
        if not wanted_skills:
            wanted_skills = ["reading", "writing", "listening", "speaking", "grammar", "vocabulary"]

        templates = {
            "reading": {
                "material_type": "reading_text",
                "title": f"Week {week_number} reading — placeholder",
                "summary": f"Skeleton reading task at {cefr_upper}. Replace via Regenerate.",
                "body": {
                    "text": (
                        "Placeholder reading passage. Replace with a "
                        f"{cefr_upper}-level text of around 400 words on "
                        "a familiar personal or study-related topic."
                    ),
                    "questions": [
                        {"q": "What is the main idea?", "type": "short_answer", "answer": "TBD"},
                    ],
                },
            },
            "writing": {
                "material_type": "writing_prompt",
                "title": f"Week {week_number} writing — placeholder",
                "summary": "Skeleton writing task. Replace via Regenerate.",
                "body": {
                    "prompt": "Write a short paragraph about your weekly routine.",
                    "word_count": 150,
                    "model_paragraph": "(Add a model paragraph after regenerating.)",
                },
            },
            "listening": {
                "material_type": "listening_script",
                "title": f"Week {week_number} listening — placeholder",
                "summary": "Skeleton listening script. Replace via Regenerate.",
                "body": {
                    "script": (
                        "(Placeholder script.) Two friends discuss their "
                        "morning routines and weekend plans."
                    ),
                    "comprehension_questions": [
                        {"q": "Who is speaking?", "answer": "Two friends."},
                    ],
                },
            },
            "speaking": {
                "material_type": "speaking_prompt",
                "title": f"Week {week_number} speaking — placeholder",
                "summary": "Skeleton speaking prompts. Replace via Regenerate.",
                "body": {
                    "prompts": [
                        "Describe a typical day in your life.",
                        "Talk about something you do at the weekend.",
                    ],
                    "useful_language": ["usually", "often", "sometimes", "never"],
                },
            },
            "grammar": {
                "material_type": "grammar_lesson",
                "title": f"Week {week_number} grammar — placeholder",
                "summary": "Skeleton grammar mini-lesson. Replace via Regenerate.",
                "body": {
                    "explanation": "Target structure for the week — replace via Regenerate.",
                    "examples": ["I work every day.", "She doesn't drink coffee."],
                    "practice": [
                        {"q": "He ___ (work) in a bank.", "answer": "works"},
                    ],
                },
            },
            "vocabulary": {
                "material_type": "vocabulary_list",
                "title": f"Week {week_number} vocabulary — placeholder",
                "summary": "Skeleton vocabulary set. Replace via Regenerate.",
                "body": {
                    "words": [
                        {"term": "routine", "pos": "n.", "definition": "a regular sequence of activities", "example": "My morning routine is busy."},
                    ],
                },
            },
        }
        materials = []
        for skill in wanted_skills:
            t = templates.get(skill)
            if t is None:
                continue
            materials.append({"skill": skill, **t})
        return {"materials": materials}

    @staticmethod
    def _flatten_body(body):
        """Produce a plain-text dump of a material body for quick preview.

        Not every shape is predictable (the model sometimes inserts
        unusual keys), so we do a shallow walk and join string values
        with newlines.
        """
        if not isinstance(body, dict):
            return ''
        lines = []
        for key, value in body.items():
            if isinstance(value, str):
                lines.append(f"{key}: {value}")
            elif isinstance(value, list):
                lines.append(f"{key}:")
                for item in value:
                    if isinstance(item, str):
                        lines.append(f"  - {item}")
                    elif isinstance(item, dict):
                        parts = []
                        for k, v in item.items():
                            if isinstance(v, (str, int, float)):
                                parts.append(f"{k}={v}")
                        if parts:
                            lines.append("  - " + ", ".join(parts))
            elif isinstance(value, dict):
                lines.append(f"{key}: " + json.dumps(value, ensure_ascii=False))
        return "\n".join(lines)
