from __future__ import annotations
"""LLM extraction for VideoInsightCardPayload using OpenAI Responses API if available.

Falls back to minimal card when LLM disabled or errors occur.
"""
from typing import Optional
import os, re
from ..config import settings
import threading as _threading, time as _time

_LAST_CALL_LOCK = _threading.Lock()
_LAST_CALL_TS: float | None = None
from ..models.video_insight import (
    VideoInsightCardPayload,
    minimal_video_card,
    video_insight_schema,
    list_problem_labels,
    VideoSection,
    VideoQuestionItem,
)
OpenAI = None  # type: ignore

def _get_openai_client(api_key: str | None):
    """Lazy import OpenAI only when we actually need it.

    Returns client instance or None if import fails / api_key missing.
    """
    global OpenAI
    if not api_key:
        return None
    if OpenAI is None:
        try:  # pragma: no cover - import side effects not under test
            from openai import OpenAI as _OpenAI  # type: ignore
            # cache symbol
            OpenAI = _OpenAI  # type: ignore
        except Exception:
            return None
    try:
        return OpenAI(api_key=api_key)  # type: ignore
    except Exception:  # pragma: no cover
        return None

_BASE_SYSTEM_PROMPT = """
You are an expert information extractor. Given an arbitrary text field (e.g., a video transcript or notes), produce a single JSON object that validates against the following target shape (Pydantic types shown for reference). Do not include Python code in your answer—output JSON only.

Target Structure (reference)
VideoInsightCardPayload = {
  "header": {
    "title": str,
    "subtitle": str|null,
    "badges": [{"label": str, "value": str|int|float}, ...]
  },
  "video": {"url": str, "id": str},
  "sections": [
    {
      "title": str,
      "items": [
        // heterogeneous list of:
        str |
        {"claim": str, "strength": "weak"|"moderate"|"strong"|"unsupported", "time": number|null,
         "evidence": [{"kind": "statistic"|"experiment"|"expert_opinion"|"anecdote"|"demonstration"|"document"|"analogy"|"reasoning"|"quote"|"other",
                       "text": str, "time": number|null}, ...],
         "assumptions": [str, ...],
         "counterarguments": [ { "text": str, "time": number|null } , ...],
         "fact_check": {"status":"unverified"|"accurate"|"false"|"mixed", "notes": str|null} | null,
         "associated_problems": [ {"label": str, "path": [str, ...]? , "questions": [str, ...]?}, ... ] },
        {"term": str, "definition": str},
        {"name": str, "time": number|null},                      // rhetoric device
        {"statement": str, "correction": str},                   // misconception
        {"q": str},                                              // question
        {"title": str, "start_s": number, "end_s": number,
         "summary": str|null, "key_points": [str, ...]|null}     // segment
      ]
      // OR, alternatively:
      // "items": {"word_count": int|null, "wpm": number|null, "lexical_diversity": number|null,
      //           "sentiment": number|null, "subjectivity": number|null}
    },
    ...
  ],
  "footer": {
    "persuasion_modes": [str, ...],
    "devices": [{"name": str, "time": number|null}, ...],
    "timeline": [{"title": str|null, "start_s": number|null, "end_s": number|null, "detail": str|null}, ...]
  }
}

Inputs you receive

TEXT: the full raw text to analyze (transcript/notes/description).

Optional hints: DEFAULT_URL, DEFAULT_ID, DEFAULT_TITLE, DEFAULT_SUBTITLE. Use when the text lacks these.

Global output rules (critical)

Output JSON only. No markdown fences, no comments.

Follow the schema exactly. Use null where a field is optional and unknown. Use empty arrays [] where applicable.

No hallucination of facts: derive from TEXT; when uncertain, prefer null, "unverified", or omit optional items.

Timestamps normalization: Convert any time you find to seconds (Number). Accept HH:MM:SS, MM:SS, 1h2m3s, or natural phrases (“at 3 minutes”).
MM:SS → 60*MM + SS
HH:MM:SS → 3600*HH + 60*MM + SS

Numbers: If numeric, use number type; otherwise string. Keep units inside strings unless clearly numeric.

Conciseness: Summaries ≤ 2–3 sentences. Bullet items are short, information-dense.

MECE: Avoid duplicate items; merge near-duplicates; keep sections non-overlapping.

What to extract (step-by-step)

A) Header
- title: Prefer a clear, human title from TEXT (video/section title). If absent, use DEFAULT_TITLE or the first heading-like line.
- subtitle: Short clarifier, series name, or tagline if present; else null or DEFAULT_SUBTITLE.
- badges: Zero or more badges you can infer (e.g., {"label":"Category","value":"AI"}, {"label":"Presenter","value":"Name"}, {"label":"Date","value":"2024-05-01"}, {"label":"Length","value":"12:34"} if present).

B) Video meta
- url: From TEXT if present; else DEFAULT_URL or "" if unknown.
- id: Extract obvious IDs (e.g., YouTube ID). Else DEFAULT_ID or "".

C) Sections (create these sections if evidence exists)
Create only sections that have content. Recommended titles and contents:

1) TL;DR & Key Takeaways
- items: a list of 5 to 7 single str sentences that build into an executive summary containing everything important

2) Actionable Next Steps
- items: list of str of what the critical thinking watcher should think about doing next after watching the video as follow up research or habits to adopt

3) Timeline Segments (chapterization)
- items: list of VideoSegmentItem with {title, start_s, end_s, summary?, key_points?}. Build contiguous, non-overlapping segments if timestamps exist; else infer coarse segments from headings or topic shifts.

4) Claims & Evidence
- items: list of VideoClaimItem. For each major assertion:
  - claim: the proposition.
  - strength: choose by support found in TEXT (strong/moderate/weak/unsupported).
  - time: earliest timestamp where the claim is stated; else null.
  - evidence: cite supporting bits with kind classification (statistic/experiment/expert_opinion/anecdote/demonstration/document/analogy/reasoning/quote/other). Include time if localized in TEXT.
  - assumptions: implicit premises.
  - counterarguments: objective counters (objects { "text": str, "time": number|null }).
    - fact_check: if the text itself evaluates accuracy, reflect it; otherwise omit or set to {"status":"unverified","notes":null}.
    - associated_problems: array (0-5) of objects each with {"label": <exact problem label string>}. Use ONLY labels from the supplied Valid Problem Labels list at the end of this prompt. Prefer the most specific label. Do NOT invent or rephrase labels. Omit if unsure.

    CLAIM COVERAGE & GRANULARITY GUIDANCE (important):
        - Aim for a comprehensive yet non-duplicative set of distinct, atomic claims capturing the breadth of the material.
        - If a claim contains multiple conjunctive ideas (A AND B), split into separate claims when each could have different evidence/counterarguments.
        - DO NOT merge thematically related but separately argued points; favor more (atomic) claims over fewer composite claims.
        - Use MIN_CLAIMS tag if provided (e.g., <MIN_CLAIMS>20</MIN_CLAIMS>) as a floor. If absent, derive a target:
                 approx_target = clamp(  max(8, floor(total_words / 700)),  12, 40 )
            (For ~2 hour videos often 18–30 claims). Exceed target if clearly justified; never pad with trivial restatements.
        - Each claim should be supportable by at least one distinct piece of evidence or clear argumentative reasoning in the text.
        - Reject micro-fragments that are mere definitional rephrasings already covered elsewhere.
        - Prefer chronological ordering by first appearance time; claims with no identifiable time go last.
        - If you produce fewer than MIN_CLAIMS / target due to lack of material, include all legitimate claims and do not hallucinate.

    
5) Glossary
- items: list of {term, definition} for domain terms/acronyms defined or implied.

6) Rhetoric & Devices
- items: list of {name, time?} for rhetorical devices (e.g., “appeal to authority”, “analogy”, “metaphor”, “rhetorical question”, “storytelling hook”) and what it actually was. Put times when they occur.

7) Misconceptions & Corrections
- items: list of {statement, correction} where the text debunks something.

8) Questions Raised
- items: list of {"q": str} for explicit or implicit critical thinking questions posed.
  
    Enrich this section beyond explicit questions in the TEXT by adding 3–8 synthesized, deeper integrative questions if they are not already present. Synthesize by:
        - Linking multiple claims (e.g., tension, trade-off, causal chain, unresolved contradiction).
        - Elevating a specific associated problem label to a broader inquiry (avoid duplicating the exact wording of that problem's built-in questions).
        - Asking about measurement / evidence gaps ("How could we empirically test …?").
        - Formulating a forward-looking or intervention question ("What interventions would reduce … while preserving …?").
        - Framing a "what if" scenario exploring boundary conditions or alternative assumptions.
    Heuristics:
        - Do NOT copy any question that already appears inside an associated_problems.questions list; create novel phrasing.
        - Avoid superficial rewordings—each synthesized question should add a new analytical dimension.
        - Prefer questions that would guide a researcher or thoughtful practitioner to the next rigorous step.
        - Keep them concise (≤ 160 characters) and self-contained (stands alone without referencing "this" or ambiguous pronouns).
    Deduplicate strictly (case-insensitive); if two questions differ only trivially, keep the more precise one.

9) Text Metrics (optional)
- If included, items is an object: {"word_count": int|null, "wpm": number|null, "lexical_diversity": number|null, "sentiment": number|null, "subjectivity": number|null}. Include only if at least word_count is reliable.

D) Footer
- persuasion_modes: list of strings capturing modes present (e.g., “logos”, “ethos”, “pathos”, “authority”, “social proof”, “scarcity”, “framing”, “visual demonstration”).
- devices: duplicates of notable rhetorical devices with times for quick jump list.
- timeline: neutral, chronological facts/events with any times you can extract. Use null for unknown fields.

Normalization & heuristics
- Quotes only inside evidence.kind="quote"; otherwise paraphrase concisely.
- Timestamp ranges (“2:10–2:55”) → use start_s/end_s where appropriate; else the first time for a claim’s time.
- If the same evidence supports multiple claims, duplicate it verbatim in each claim’s evidence list.
- Keep arrays ordered by first-occurrence time; items with no time come last.

Quality checks before finalizing
- JSON is valid and parsable.
- All times are numbers in seconds or null.
- No empty strings; use null instead.
- No fields beyond the schema.
- No markdown.

Now, perform the extraction
- Transform the provided TEXT (and optional defaults) into a single JSON object conforming to VideoInsightCardPayload.
- If any required top-level field is missing, still output a valid object using fallbacks:
  - Unknown strings → ""
  - Unknown lists → []
  - Unknown optionals → null
  - fact_check → omit or set {"status":"unverified","notes":null}

Return only the JSON.
""".strip()


def _build_system_prompt() -> str:
    labels = list_problem_labels()
    # Keep labels concise; provide as single JSON array for easier model grounding.
    import json as _json
    labels_json = _json.dumps(labels, ensure_ascii=False)
    return _BASE_SYSTEM_PROMPT + "\n\nValid Problem Labels (exact match only JSON array):\n" + labels_json + "\n"


SYSTEM_PROMPT = _build_system_prompt()


def _normalize_q(q: str) -> str:
    import re as _re
    # Lowercase, remove non-alphanumeric, collapse whitespace.
    q = q.lower()
    q = _re.sub(r"[^a-z0-9]+", " ", q)
    q = _re.sub(r"\s+", " ", q).strip()
    return q


_QUESTION_STOPWORDS = {
    "how","what","which","why","when","where","can","could","should","would","do","does","is","are","the","a","an","of","to","for","in","and","or","we","our","on","with","at","by","from"
}


def _token_set(norm_q: str) -> set[str]:
    return {t for t in norm_q.split() if t not in _QUESTION_STOPWORDS}


def _is_near_duplicate(norm_q: str, tokens: set[str], existing: list[tuple[str,set[str]]]) -> bool:
    # Check substring containment
    for eq_norm, eq_tokens in existing:
        if norm_q == eq_norm:
            return True
        if norm_q in eq_norm or eq_norm in norm_q:
            return True
        # Jaccard similarity on token sets
        if eq_tokens and tokens:
            inter = len(eq_tokens & tokens)
            union = len(eq_tokens | tokens)
            if union and inter / union >= 0.8:
                return True
    return False


def _shorten_claim(claim: str, max_words: int = 8) -> str:
    words = [w for w in claim.split() if w]
    if len(words) <= max_words:
        return claim.rstrip('.? ')  # preserve original short
    return " ".join(words[:max_words]).rstrip('.? ') + "…"


def _synthesize_pair_questions(a: str, b: str) -> list[str]:
    # Given two short claim fragments, produce a small set of analytical question templates.
    return [
        f"Under what conditions does {a} intensify or mitigate {b}?",
        f"What evidence would best isolate the causal link between {a} and {b}?",
        f"Which interventions could reduce harms of {a} without exacerbating {b}?",
        f"How might progress in {a} create second-order effects on {b}?",
    ]


def _enrich_questions(card: VideoInsightCardPayload, *, target_min: int = 8, max_total: int = 14) -> None:
    """Ensure Questions Raised section contains novel, integrative questions.

    Steps:
      1. Collect all ontology-provided problem questions to avoid duplication.
      2. Remove exact or near duplicates (case/punctuation-insensitive) in existing Questions Raised.
      3. If total < target_min, synthesize additional integrative questions by pairing distinct claims.
      4. Deduplicate synthesized vs existing; cap at max_total.
    Mutates card in-place.
    """
    # Gather existing section
    q_section: VideoSection | None = None
    for sec in card.sections:
        if isinstance(sec.items, list) and sec.title.lower().startswith("questions raised"):
            q_section = sec  # type: ignore[assignment]
            break
    if q_section is None:
        # create section if missing
        q_section = VideoSection(title="Questions Raised", items=[])
        card.sections.append(q_section)

    # Collect ontology problem questions (may be suppressed entirely)
    ontology_qs: set[str] = set()
    if not settings.suppress_problem_questions:
        for sec in card.sections:
            if not isinstance(sec.items, list):
                continue
            for item in sec.items:
                from ..models.video_insight import VideoClaimItem, Problem  # local import to avoid circular hints
                if isinstance(item, VideoClaimItem):
                    for prob in item.associated_problems:
                        if hasattr(prob, 'questions') and isinstance(prob.questions, list):
                            ontology_qs.update(_normalize_q(q) for q in prob.questions)

    # Normalize existing questions and filter duplicates
    existing: list[VideoQuestionItem] = []
    seen_norm: set[str] = set()
    seen_detailed: list[tuple[str,set[str]]] = []  # (norm_q, token_set)
    for raw in list(q_section.items):  # type: ignore[attr-defined]
        if isinstance(raw, VideoQuestionItem):
            norm = _normalize_q(raw.q)
            tok = _token_set(norm)
            if norm and norm not in ontology_qs and not _is_near_duplicate(norm, tok, seen_detailed):
                existing.append(raw)
                seen_norm.add(norm)
                seen_detailed.append((norm, tok))
        elif isinstance(raw, dict) and 'q' in raw:  # tolerate raw dict from LLM
            norm = _normalize_q(str(raw['q']))
            tok = _token_set(norm)
            if norm and norm not in ontology_qs and not _is_near_duplicate(norm, tok, seen_detailed):
                existing.append(VideoQuestionItem(q=raw['q']))
                seen_norm.add(norm)
                seen_detailed.append((norm, tok))
    # Replace with cleaned list
    q_section.items = existing  # type: ignore[assignment]

    if len(existing) >= target_min or settings.suppress_problem_questions:
        return

    # Collect claim fragments
    claim_fragments: list[str] = []
    from ..models.video_insight import VideoClaimItem as _VCI
    for sec in card.sections:
        if not isinstance(sec.items, list):
            continue
        for item in sec.items:
            if isinstance(item, _VCI):
                frag = _shorten_claim(item.claim)
                if frag not in claim_fragments:
                    claim_fragments.append(frag)
            elif isinstance(item, str):  # ignore
                continue
    # Pairwise synthesis
    synthesized: list[str] = []
    for i in range(len(claim_fragments)):
        if len(existing) + len(synthesized) >= max_total:
            break
        for j in range(i + 1, len(claim_fragments)):
            if len(existing) + len(synthesized) >= max_total:
                break
            a, b = claim_fragments[i], claim_fragments[j]
            for q in _synthesize_pair_questions(a, b):
                norm = _normalize_q(q)
                tok = _token_set(norm)
                if norm in ontology_qs or _is_near_duplicate(norm, tok, seen_detailed):
                    continue
                synthesized.append(q)
                seen_norm.add(norm)
                seen_detailed.append((norm, tok))
                if len(existing) + len(synthesized) >= target_min:
                    break
            if len(existing) + len(synthesized) >= target_min:
                break
    # Append synthesized
    for q in synthesized:
        if len(q_section.items) < max_total:  # type: ignore[arg-type]
            q_section.items.append(VideoQuestionItem(q=q))  # type: ignore[attr-defined]


def _llm_expand_questions(
    card: VideoInsightCardPayload,
    client: "OpenAI",  # type: ignore[name-defined]
    *,
    model: str,
    max_new: int = 8,
) -> None:
    """Use the LLM to propose additional novel, integrative questions.

    We provide:
      - Existing Questions Raised (after dedup/local enrichment)
      - All ontology problem questions already implicitly used (to avoid duplicates)
      - Claim texts (truncated)

    The model returns JSON {"new_questions":[...]}; we merge unique ones.
    """
    # Safety: gather existing & ontology
    from ..models.video_insight import VideoClaimItem, Problem  # local import
    # Find Questions section
    q_sec = None
    for sec in card.sections:
        if isinstance(sec.items, list) and sec.title.lower().startswith("questions raised"):
            q_sec = sec
            break
    if q_sec is None:
        return
    existing_q_items = [i for i in q_sec.items if isinstance(i, VideoQuestionItem)]  # type: ignore[attr-defined]
    existing_norm = {_normalize_q(i.q) for i in existing_q_items}
    existing_detailed = [(n, _token_set(n)) for n in existing_norm]
    ontology_norm: set[str] = set()
    for sec in card.sections:
        if not isinstance(sec.items, list):
            continue
        for it in sec.items:
            if isinstance(it, VideoClaimItem):
                for prob in it.associated_problems:
                    if isinstance(prob, Problem):
                        for q in prob.questions:
                            ontology_norm.add(_normalize_q(q))
    # Claims
    claim_texts: list[str] = []
    for sec in card.sections:
        if not isinstance(sec.items, list):
            continue
        for it in sec.items:
            if isinstance(it, VideoClaimItem):
                claim_texts.append(it.claim[:220])
    # Build prompt (compact JSON-like format to reduce tokens)
    import json as _json
    payload = {
        "existing_questions": [i.q for i in existing_q_items],
        "ontology_problem_questions_used": list(ontology_norm),
        "claims": claim_texts[:25],
        "instructions": (
            "Generate up to "
            + str(max_new)
            + " NEW integrative, analytical questions that are NOT paraphrases of any provided question. "
              "Each should: (a) combine ideas from >=2 distinct claims or surface a tension, (b) be <=160 characters, "
              "(c) stand alone without ambiguous pronouns, (d) avoid starting with 'How can we' more than twice, "
              "(e) avoid generic phrasing like 'What about' or 'Is there', (f) not duplicate ontology problem questions. "
              "Return JSON only: {\"new_questions\":[...]}"
        ),
    }
    messages = [
        {"role": "system", "content": "You are a rigorous research question generator returning ONLY JSON."},
        {"role": "user", "content": _json.dumps(payload, ensure_ascii=False)},
    ]
    try:
        chat = getattr(client, "chat", None)
        comp = None
        if chat and hasattr(chat, "completions"):
            comp = chat.completions.create(model=model, messages=messages, temperature=0.3)
        else:
            responses = getattr(client, "responses", None)
            if responses and hasattr(responses, "create"):
                comp = responses.create(model=model, input=messages)
        if not comp:
            return
        # Extract text
        text = None
        try:
            # responses API style
            out = getattr(comp, "output", None)
            if out:
                # Flatten any text segments
                parts = []
                for seg in out:
                    for c in getattr(seg, "content", []) or []:
                        val = getattr(c, "text", None)
                        if val and hasattr(val, "value"):
                            parts.append(val.value)
                if parts:
                    text = "\n".join(parts)
        except Exception:
            pass
        if text is None:
            try:
                text = comp.choices[0].message.content  # type: ignore[attr-defined]
            except Exception:
                text = None
        if not text:
            return
        import re as _re, json as _json2
        m = _re.search(r"{[\s\S]+}", text)
        raw = m.group(0) if m else text
        data = _json2.loads(raw)
        new_questions = data.get("new_questions") if isinstance(data, dict) else None
        if not isinstance(new_questions, list):
            return
        for q in new_questions:
            if not isinstance(q, str):
                continue
            norm = _normalize_q(q)
            tok = _token_set(norm)
            if (
                not norm
                or norm in ontology_norm
                or _is_near_duplicate(norm, tok, existing_detailed)
            ):
                continue
            q_sec.items.append(VideoQuestionItem(q=q))  # type: ignore[attr-defined]
            existing_norm.add(norm)
            existing_detailed.append((norm, tok))
    except Exception:
        # silent failure; enrichment is best-effort
        return


def _video_id_from_url(url_or_id: str) -> str:
    if len(url_or_id) == 11 and re.match(r"^[A-Za-z0-9_-]{11}$", url_or_id):
        return url_or_id
    m = re.search(r"(?:v=|youtu\.be/|/shorts/)([A-Za-z0-9_-]{11})", url_or_id)
    return m.group(1) if m else url_or_id


def llm_video_insight_from_transcript(
    transcript: str,
    *,
    url: str,
    title: Optional[str] = None,
    subtitle: Optional[str] = None,
    model: Optional[str] = None,
    video_id: Optional[str] = None,
) -> VideoInsightCardPayload:
    """Attempt structured extraction; fallback to minimal card."""
    vid = video_id or _video_id_from_url(url)
    title = title or "YouTube Video"
    if not settings.llm_enabled:  # fallback path (no API key)
        return minimal_video_card(url=url, video_id=vid, title=title, subtitle=subtitle, transcript_text=transcript)
    api_key = settings.openai_api_key or os.getenv("OPENAI_API_KEY")
    if not api_key:  # safety (double-check)
        return minimal_video_card(url=url, video_id=vid, title=title, subtitle=subtitle, transcript_text=transcript)
    # Simple rate limiting (coarse, per-process) if configured
    if settings.llm_min_interval_s > 0:
        global _LAST_CALL_TS
        with _LAST_CALL_LOCK:
            now = _time.time()
            if _LAST_CALL_TS is not None:
                wait_for = settings.llm_min_interval_s - (now - _LAST_CALL_TS)
                if wait_for > 0:
                    _time.sleep(wait_for)
            _LAST_CALL_TS = _time.time()
    client = _get_openai_client(api_key)
    if client is None:  # could not import or init
        return minimal_video_card(url=url, video_id=vid, title=title, subtitle=subtitle, transcript_text=transcript)
    schema = video_insight_schema()
    defaults_blob = [f"<DEFAULT_URL>{url}</DEFAULT_URL>", f"<DEFAULT_ID>{vid}</DEFAULT_ID>"]
    if title:
        defaults_blob.append(f"<DEFAULT_TITLE>{title}</DEFAULT_TITLE>")
    if subtitle:
        defaults_blob.append(f"<DEFAULT_SUBTITLE>{subtitle}</DEFAULT_SUBTITLE>")
    # Estimate desired claim count (rough heuristic) and inject hint
    word_count = len(transcript.split())
    import math as _m
    approx_target = max(8, int(word_count / 700))  # ~1 claim per ~700 words
    approx_target = max(12, min(approx_target, 40))  # clamp 12..40
    defaults_blob.append(f"<MIN_CLAIMS>{approx_target}</MIN_CLAIMS>")
    user_content = "\n".join([*defaults_blob, f"<TEXT>{transcript}</TEXT>"])
    import logging as _logging, time as _time
    try:  # use responses.parse if available; degrade gracefully
        # Newer OpenAI SDK (>=1.0) responses.parse
        responses = getattr(client, "responses", None)
        if responses and hasattr(responses, "parse"):
            last_err: Exception | None = None
            for attempt in range(settings.llm_max_retries + 1):
                try:
                    chosen_model = model or settings.llm_model or "gpt-5"
                    # Allow adaptive minimal prompt if headers earlier indicated low remaining tokens
                    system_prompt = SYSTEM_PROMPT
                    if settings.adapt_rate_limits and os.getenv("GRANULE_LLM_FORCE_MINIMAL_PROMPT"):
                        # Minimal prompt: only essential extraction directives
                        system_prompt = "You extract a concise JSON VideoInsightCardPayload from transcript text. Keep it short: 5 key takeaways, up to 12 claims, minimal evidence, questions only if present. Return valid JSON."
                    resp = responses.parse(
                        model=chosen_model,
                        input=[
                            {"role": "system", "content": system_prompt},
                            {"role": "user", "content": user_content},
                        ],
                        text_format=VideoInsightCardPayload,
                    )
                    parsed = getattr(resp, "output_parsed", None)
                    if parsed is None:  # fallback path exploring output attr
                        try:  # pragma: no cover - defensive
                            parsed = resp.output[0].content[0].parsed
                        except Exception:  # noqa: PERF203
                            parsed = None
                    if parsed:
                        card = VideoInsightCardPayload.model_validate(parsed)
                        try:  # enrichment best-effort
                            _enrich_questions(card)
                            if (
                                settings.llm_enabled
                                and api_key
                                and not settings.suppress_problem_questions
                                and not settings.disable_question_expand
                            ):
                                _llm_expand_questions(
                                    card, client, model=model or settings.llm_model or "gpt-5"
                                )
                            _enrich_questions(card)
                        except Exception:  # pragma: no cover
                            pass
                        if attempt > 0:
                            _logging.getLogger("granule.llm").info(
                                "LLM parse succeeded after %s retry(s)", attempt
                            )
                        return card
                    # If no parsed output, treat as error to potentially retry
                    raise RuntimeError("No parsed output from responses.parse")
                except Exception as e:  # rate limit / transient
                    last_err = e
                    # Adaptive wait: look for rate limit reset hints if available
                    wait_extra = 0.0
                    if settings.adapt_rate_limits:
                        hdrs = None
                        for attr in ("response", "http_response", "_response"):
                            resp_obj = getattr(e, attr, None)
                            if resp_obj is not None:
                                hdrs = getattr(resp_obj, "headers", None)
                                if hdrs:
                                    break
                        if hdrs:
                            try:
                                rem_req = hdrs.get("x-ratelimit-remaining-requests")
                                rem_tok = hdrs.get("x-ratelimit-remaining-tokens")
                                reset_req = hdrs.get("x-ratelimit-reset-requests")
                                reset_tok = hdrs.get("x-ratelimit-reset-tokens")
                                def _parse_reset(val: str | None) -> float:
                                    if not val:
                                        return 0.0
                                    import re as _re
                                    m = _re.findall(r"(\d+)([smh])", val)
                                    total = 0.0
                                    for num, unit in m:
                                        num = int(num)
                                        if unit == 's':
                                            total += num
                                        elif unit == 'm':
                                            total += num * 60
                                        elif unit == 'h':
                                            total += num * 3600
                                    return total
                                r_req = _parse_reset(reset_req)
                                r_tok = _parse_reset(reset_tok)
                                if rem_req is not None:
                                    try:
                                        if int(rem_req) <= 1 and r_req:
                                            wait_extra = max(wait_extra, min(r_req, 5.0))
                                    except Exception:
                                        pass
                                if rem_tok is not None:
                                    try:
                                        if int(rem_tok) < 2000 and r_tok:
                                            wait_extra = max(wait_extra, min(r_tok, 10.0))
                                            os.environ.setdefault("GRANULE_LLM_FORCE_MINIMAL_PROMPT", "1")
                                    except Exception:
                                        pass
                            except Exception:
                                pass
                    if attempt < settings.llm_max_retries:
                        backoff = 0.8 * (attempt + 1) + wait_extra
                        _logging.getLogger("granule.llm").warning(
                            "LLM attempt %s failed (%s). Retrying in %.2fs (adaptive wait %.2fs)", attempt, type(e).__name__, backoff, wait_extra
                        )
                        _time.sleep(backoff)
                        continue
                    _logging.getLogger("granule.llm").error(
                        "LLM failed after %s attempts: %s", attempt, repr(e)
                    )
                    break
            # On failure cascade to fallback minimal card
            if last_err and settings.llm_enabled:
                _logging.getLogger("granule.llm").info(
                    "Falling back to minimal card due to error: %s", type(last_err).__name__
                )
        # Fallback: generic chat completion with schema hint (less strict)
        chat = getattr(client, "chat", None)
        if chat and hasattr(chat, "completions"):
            prompt = (
                "Return ONLY JSON validating this schema:" + str(schema) + "\nTranscript:\n" + transcript[:24000]
            )
            comp = chat.completions.create(
                model=model or settings.llm_model or "gpt-5",
                messages=[
                    {"role": "system", "content": SYSTEM_PROMPT},
                    {"role": "user", "content": prompt},
                ],
                temperature=0.1,
            )
            text = comp.choices[0].message.content or ""
            import json, json as _j
            import re as _r
            m = _r.search(r"{[\s\S]+}", text)
            raw = m.group(0) if m else text
            try:
                data = json.loads(raw)
                if isinstance(data, dict):
                    return VideoInsightCardPayload.model_validate(data)
            except Exception:  # pragma: no cover
                pass
    except Exception:  # pragma: no cover
        pass
    return minimal_video_card(url=url, video_id=vid, title=title, subtitle=subtitle, transcript_text=transcript)
