"""Multimedia generation service for course-plan materials.

Three modalities — each persists an ``encoach.course.plan.media`` row
with the bytes attached as an ``ir.attachment`` so the existing
``/web/content/<id>`` URL serving works without extra plumbing.

PROVIDER FALLBACK CHAIN (Phase 24.1, Apr 2026)
==============================================
Every modality now tries providers in order: the explicitly-requested or
admin-configured paid provider first, then a sequence of *free* fallbacks.
A request that hits a billing/quota error (HTTP 402/429, OpenAI
``insufficient_quota``, AWS Polly ``ThrottlingException``, ElevenLabs
character-limit, etc.) is silently retried against the next provider in
the chain — the request never fails just because the admin's API key has
run out of credit.

* Image:  ``openai (DALL-E 3) → pillow (offline placeholder) → unsplash``.
* Audio:  ``polly | elevenlabs → gtts → silent-stub``.
* Video:  ``ffmpeg (image+audio) → static (text-only image card)``.

The active provider per capability is read fresh from
``ir.config_parameter`` on every call (see
:mod:`encoach_ai.services.provider_router`) so toggling a provider in
the admin UI takes effect on the very next request without restarting.
"""

from __future__ import annotations

import base64
import logging
import os
import shutil
import subprocess
import tempfile
import time
from typing import Optional

_logger = logging.getLogger(__name__)

from odoo.addons.encoach_ai.services import provider_router
from odoo.addons.encoach_ai.services.provider_router import (
    classify_provider_error,
    should_fallback,
)


# --- Helpers ----------------------------------------------------------------


def _attach_bytes(env, *, name, mime_type, data: bytes,
                   res_model: str | None = None,
                   res_id: int | None = None,
                   public: bool = True):
    """Persist ``data`` as an ``ir.attachment`` and return the record."""
    Attachment = env['ir.attachment'].sudo()
    return Attachment.create({
        'name': name,
        'type': 'binary',
        'datas': base64.b64encode(data),
        'mimetype': mime_type or 'application/octet-stream',
        'res_model': res_model or False,
        'res_id': res_id or 0,
        'public': bool(public),
    })


def _get_param(env, key, default):
    return env['ir.config_parameter'].sudo().get_param(key, default)


def _enforce_image_budget(env, plan, planned_images: int = 1) -> None:
    """Raise if generating ``planned_images`` would exceed the per-plan cap.

    The budget only applies to *paid* image providers (DALL-E). Free
    fallbacks (Pillow, Unsplash) are unmetered.
    """
    cap = int(_get_param(env, 'encoach_ai_course.image_budget_per_plan', '60'))
    if cap <= 0:
        return
    Media = env['encoach.course.plan.media'].sudo()
    used = Media.search_count([
        ('plan_id', '=', plan.id),
        ('kind', '=', 'image'),
        ('provider', '=', 'openai_image'),
        ('status', 'in', ('ready', 'generating')),
    ])
    if used + planned_images > cap:
        raise RuntimeError(
            f'Paid image budget exceeded for this plan: {used} used, cap is '
            f'{cap}. Raise encoach_ai_course.image_budget_per_plan or delete '
            f'old DALL-E images. Free Pillow/Unsplash fallbacks remain available.'
        )


def _build_audio_script(material) -> str:
    """Choose the right text from a material's body for narration."""
    body = material._loads(material.body_json, {}) or {}
    if material.material_type == 'listening_script':
        return (body.get('script') or '').strip()
    if material.material_type == 'speaking_prompt':
        if isinstance(body.get('model_answer'), str) and body['model_answer'].strip():
            return body['model_answer'].strip()
        prompts = body.get('prompts') or []
        if prompts:
            return ' '.join(str(p) for p in prompts).strip()
    if material.material_type == 'reading_text':
        return (body.get('text') or '').strip()
    return (material.body_text or '').strip()


def _build_image_prompt(material, *, plan) -> str:
    """Construct a DALL-E prompt from the material content + plan CEFR."""
    body = material._loads(material.body_json, {}) or {}
    cefr = (plan.cefr_level or '').upper() or 'A2'
    style_hint = (
        'flat illustration, soft pastel palette, friendly textbook style, '
        'clean white background, high contrast, no text, no watermarks'
    )
    if material.material_type == 'reading_text':
        snippet = (body.get('text') or '')[:300].strip()
        return (
            f'Editorial illustration for an English language reading lesson '
            f'(CEFR {cefr}) titled "{material.title}". Subject: {snippet} '
            f'Style: {style_hint}.'
        )
    if material.material_type == 'listening_script':
        snippet = (body.get('script') or '')[:300].strip()
        return (
            f'Illustration showing the scene of an English listening '
            f'lesson dialogue (CEFR {cefr}) titled "{material.title}". '
            f'Scene: {snippet} Style: {style_hint}.'
        )
    if material.material_type == 'vocabulary_list':
        words = body.get('words') or []
        if words:
            term = words[0].get('term') or material.title
            return (
                f'Single concrete object representing the English word '
                f'"{term}" for a CEFR {cefr} vocabulary flashcard. {style_hint}.'
            )
    return (
        f'Illustration for an English language teaching material (CEFR '
        f'{cefr}) titled "{material.title}". {style_hint}.'
    )


def _image_subtitle(material, *, plan):
    """Short subtitle string used by the offline placeholder card."""
    cefr = (plan.cefr_level or '').upper() or '—'
    parts = [f'CEFR {cefr}']
    if material.week_number:
        parts.append(f'Week {material.week_number}')
    if material.material_type:
        parts.append(material.material_type.replace('_', ' ').title())
    return ' · '.join(parts)


# --- Public service ---------------------------------------------------------


class MediaService:
    """Generate audio / image / video assets for a course-plan material.

    All three public methods (:meth:`synthesize_audio`,
    :meth:`generate_image`, :meth:`compose_video`) walk a provider chain
    and degrade gracefully — they never raise to the controller as long
    as at least one free fallback is wired up. The persisted
    ``encoach.course.plan.media`` row records *which* provider actually
    succeeded, plus any errors hit along the way (concatenated into
    ``error`` for diagnostics).
    """

    def __init__(self, env):
        self.env = env

    # -- Audio -----------------------------------------------------------
    def synthesize_audio(self, material, *, voice: Optional[str] = None,
                          language: str = 'en-GB',
                          gender: str = 'female',
                          provider: str = 'auto') -> 'models.Model':
        """Generate a narration MP3 for ``material`` and persist it.

        The ``provider`` argument behaves like the admin setting:

        * ``'auto'`` — try paid then free fallbacks
        * a specific name (``polly``, ``elevenlabs``, ``gtts``,
          ``silent``) — pin that provider; the chain still falls back
          to the free providers if it errors out

        Even if every provider fails, the row is marked ``failed`` with
        a helpful error rather than raising.
        """
        Media = self.env['encoach.course.plan.media'].sudo()
        media = Media.create({
            'plan_id': material.plan_id.id,
            'week_id': material.week_id.id if material.week_id else False,
            'material_id': material.id,
            'kind': 'audio',
            'provider': provider,
            'title': f'{material.title} — narration',
            'language': language,
            'voice': voice or '',
            'status': 'generating',
        })
        text = _build_audio_script(material)
        if not text:
            media.write({'status': 'failed', 'error': 'No script text to narrate'})
            return media
        media.write({'source_text': text[:3000]})

        chain = provider_router.resolve_chain(
            self.env, 'audio', requested=provider if provider != 'auto' else None,
        )
        errors = []
        for prov in chain:
            try:
                result = self._call_audio_provider(
                    prov, text=text, voice=voice,
                    language=language, gender=gender,
                )
                content_type = result.get('content_type', 'audio/mpeg')
                ext = 'wav' if content_type == 'audio/wav' else 'mp3'
                attach = _attach_bytes(
                    self.env,
                    name=f'plan-{material.plan_id.id}-week-{material.week_number}'
                         f'-{material.material_type}-{material.id}.{ext}',
                    mime_type=content_type,
                    data=result['audio'],
                    res_model='encoach.course.plan.media',
                    res_id=media.id,
                )
                media.write({
                    'attachment_id': attach.id,
                    'mime_type': content_type,
                    'size_bytes': len(result['audio']),
                    'voice': result.get('voice') or voice or '',
                    'provider': prov,
                    'status': 'ready',
                    'error': '\n'.join(errors)[:500] if errors else False,
                })
                return media
            except Exception as exc:
                kind = classify_provider_error(exc)
                msg = f'[{prov}/{kind}] {str(exc)[:200]}'
                errors.append(msg)
                _logger.warning(
                    'TTS provider %s failed (%s) for material %s — trying next',
                    prov, kind, material.id,
                )
                if not should_fallback(exc) and prov != chain[-1]:
                    # ``other`` errors (not quota/auth/network) suggest a
                    # genuine input problem, not provider exhaustion. Keep
                    # falling back anyway because the user just wants audio
                    # produced — but log loudly so we notice in production.
                    _logger.exception(
                        'Unclassified TTS error from %s — continuing chain', prov,
                    )
        media.write({
            'status': 'failed',
            'error': ('All audio providers failed: '
                      + ' | '.join(errors))[:500],
        })
        return media

    def _call_audio_provider(self, provider, *, text, voice, language, gender):
        if provider == 'polly':
            from odoo.addons.encoach_ai.services.polly_service import PollyService
            return PollyService(self.env).synthesize(
                text, voice=voice, language=language, gender=gender,
            )
        if provider == 'elevenlabs':
            from odoo.addons.encoach_ai.services.elevenlabs_service import (
                ElevenLabsService,
            )
            res = ElevenLabsService(self.env).synthesize(
                text, voice_id=voice or None,
            )
            return {
                'audio': res.get('audio') or res.get('audio_bytes') or b'',
                'content_type': res.get('content_type', 'audio/mpeg'),
                'voice': res.get('voice') or voice or 'elevenlabs',
                'characters': len(text),
            }
        if provider == 'gtts':
            from odoo.addons.encoach_ai.services.free_tts import (
                synthesize_with_gtts,
            )
            return synthesize_with_gtts(text, language=language)
        if provider == 'silent':
            from odoo.addons.encoach_ai.services.free_tts import synthesize_silent
            # Pick a duration roughly proportional to the script so the
            # silent stub still gives the video composer enough length.
            seconds = max(1, min(30, len(text) // 12))
            return synthesize_silent(duration_seconds=seconds)
        raise RuntimeError(f'Unknown audio provider: {provider!r}')

    # -- Image -----------------------------------------------------------
    def generate_image(self, material, *,
                        custom_prompt: Optional[str] = None,
                        size: str = '1024x1024',
                        style: str = 'natural',
                        quality: str = 'standard',
                        provider: str = 'auto') -> 'models.Model':
        """Generate an illustration for ``material`` with provider fallback."""
        Media = self.env['encoach.course.plan.media'].sudo()
        plan = material.plan_id
        prompt = (custom_prompt or _build_image_prompt(material, plan=plan)).strip()
        media = Media.create({
            'plan_id': plan.id,
            'week_id': material.week_id.id if material.week_id else False,
            'material_id': material.id,
            'kind': 'image',
            'provider': provider,
            'title': f'{material.title} — illustration',
            'source_text': prompt[:3000],
            'style': style,
            'status': 'generating',
        })

        chain = provider_router.resolve_chain(
            self.env, 'image', requested=provider if provider != 'auto' else None,
        )
        errors = []
        for prov in chain:
            try:
                # Only the paid OpenAI provider is metered against the budget.
                if prov in ('openai', 'openai_image'):
                    _enforce_image_budget(self.env, plan, planned_images=1)
                result = self._call_image_provider(
                    prov, prompt=prompt, size=size, style=style,
                    quality=quality, material=material, plan=plan,
                )
                provider_label = 'openai_image' if prov in (
                    'openai', 'openai_image') else prov
                attach = _attach_bytes(
                    self.env,
                    name=f'plan-{plan.id}-week-{material.week_number}'
                         f'-{material.material_type}-{material.id}.png',
                    mime_type=result.get('mime_type', 'image/png'),
                    data=result['image'],
                    res_model='encoach.course.plan.media',
                    res_id=media.id,
                )
                try:
                    w, h = (int(s) for s in size.split('x'))
                except Exception:
                    w, h = 0, 0
                media.write({
                    'attachment_id': attach.id,
                    'mime_type': result.get('mime_type', 'image/png'),
                    'size_bytes': len(result['image']),
                    'width': w,
                    'height': h,
                    'provider': provider_label,
                    'status': 'ready',
                    'error': '\n'.join(errors)[:500] if errors else False,
                    'cost_cents': (4 if quality == 'standard' else 8)
                    if prov in ('openai', 'openai_image') else 0,
                })
                return media
            except Exception as exc:
                kind = classify_provider_error(exc)
                msg = f'[{prov}/{kind}] {str(exc)[:200]}'
                errors.append(msg)
                _logger.warning(
                    'Image provider %s failed (%s) for material %s — trying next',
                    prov, kind, material.id,
                )
        media.write({
            'status': 'failed',
            'error': ('All image providers failed: '
                      + ' | '.join(errors))[:500],
        })
        return media

    def _call_image_provider(self, provider, *, prompt, size, style, quality,
                              material, plan):
        if provider in ('openai', 'openai_image'):
            from odoo.addons.encoach_ai.services.openai_service import (
                OpenAIService,
            )
            res = OpenAIService(self.env).generate_image(
                prompt, size=size, style=style, quality=quality,
            )
            return {
                'image': res['image'],
                'mime_type': 'image/png',
            }
        if provider == 'pillow':
            from odoo.addons.encoach_ai.services.free_image import (
                render_placeholder,
            )
            png = render_placeholder(
                material.title or 'Course material',
                subtitle=_image_subtitle(material, plan=plan),
                size=size,
                seed=material.id,
            )
            return {'image': png, 'mime_type': 'image/png'}
        if provider == 'unsplash':
            return self._fetch_unsplash(prompt, size=size)
        if provider == 'mock':
            from odoo.addons.encoach_ai.services.free_image import (
                render_placeholder,
            )
            png = render_placeholder(
                'Mock provider',
                subtitle=material.title or '',
                size=size,
                seed=material.id,
            )
            return {'image': png, 'mime_type': 'image/png'}
        raise RuntimeError(f'Unknown image provider: {provider!r}')

    def _fetch_unsplash(self, prompt, *, size='1024x1024'):
        """Free Unsplash Source endpoint — no API key required.

        Falls back to the offline Pillow placeholder if the network call
        fails so this provider, like all the others, is non-blocking.
        """
        try:
            import requests
        except ImportError as exc:  # pragma: no cover
            raise RuntimeError('requests not installed') from exc
        # The "source" endpoint returns a redirect to a JPEG that matches
        # the keywords. We deliberately use only the first 5 keywords to
        # keep the URL short.
        words = ' '.join((prompt or '').split()[:5]).strip() or 'education'
        try:
            w, h = size.lower().split('x')
        except Exception:
            w, h = '1024', '1024'
        url = f'https://source.unsplash.com/{w}x{h}/?{words}'
        resp = requests.get(url, timeout=15, allow_redirects=True)
        if resp.status_code != 200 or not resp.content:
            raise RuntimeError(
                f'Unsplash returned HTTP {resp.status_code}'
            )
        return {'image': resp.content, 'mime_type': 'image/jpeg'}

    # -- Video -----------------------------------------------------------
    def compose_video(self, material, *, audio_media=None,
                       image_media=None,
                       provider: str = 'auto') -> 'models.Model':
        """Compose a slide-style MP4 (image + audio) for ``material``.

        Strategy:

        1. Try ``ffmpeg`` (real slideshow video) if ffmpeg is on PATH.
        2. Fall back to ``static`` — a 5-second MP4 generated purely
           from the placeholder image without external audio.

        Like the audio/image methods, this never raises to the caller;
        it always returns a media row whose ``status`` reflects success
        or failure.
        """
        Media = self.env['encoach.course.plan.media'].sudo()
        plan = material.plan_id
        media = Media.create({
            'plan_id': plan.id,
            'week_id': material.week_id.id if material.week_id else False,
            'material_id': material.id,
            'kind': 'video',
            'provider': provider,
            'title': f'{material.title} — slideshow',
            'status': 'generating',
        })

        chain = provider_router.resolve_chain(
            self.env, 'video', requested=provider if provider != 'auto' else None,
        )
        errors = []
        for prov in chain:
            try:
                if prov == 'ffmpeg':
                    return self._compose_video_ffmpeg(
                        media, material, audio_media, image_media,
                    )
                if prov == 'static':
                    return self._compose_video_static(media, material)
                raise RuntimeError(f'Unknown video provider: {prov!r}')
            except Exception as exc:
                kind = classify_provider_error(exc)
                msg = f'[{prov}/{kind}] {str(exc)[:200]}'
                errors.append(msg)
                _logger.warning(
                    'Video provider %s failed (%s) for material %s — trying next',
                    prov, kind, material.id,
                )
        media.write({
            'status': 'failed',
            'error': ('All video providers failed: '
                      + ' | '.join(errors))[:500],
        })
        return media

    # ── Concrete video providers ────────────────────────────────────────

    def _compose_video_ffmpeg(self, media, material, audio_media, image_media):
        """Real slideshow MP4 via ffmpeg. Raises if ffmpeg not on PATH."""
        if shutil.which('ffmpeg') is None:
            raise RuntimeError('ffmpeg not found on PATH')

        plan = material.plan_id
        audio = audio_media or material.media_ids.filtered(
            lambda m: m.kind == 'audio' and m.status == 'ready'
        )[:1]
        if not audio:
            audio = self.synthesize_audio(material)
            if audio.status != 'ready':
                raise RuntimeError(
                    f'Audio prerequisite not ready: {audio.error or "unknown"}'
                )
        image = image_media or material.media_ids.filtered(
            lambda m: m.kind == 'image' and m.status == 'ready'
        )[:1]
        if not image:
            image = self.generate_image(material)
            if image.status != 'ready':
                raise RuntimeError(
                    f'Image prerequisite not ready: {image.error or "unknown"}'
                )
        audio_attach = audio.attachment_id
        image_attach = image.attachment_id
        if not audio_attach or not image_attach:
            raise RuntimeError('Missing audio/image attachments')

        audio_bytes = base64.b64decode(audio_attach.datas)
        image_bytes = base64.b64decode(image_attach.datas)

        with tempfile.TemporaryDirectory(prefix='encoach_video_') as tmp:
            a_path = os.path.join(tmp, 'audio.mp3')
            i_path = os.path.join(tmp, 'image.png')
            v_path = os.path.join(tmp, 'out.mp4')
            with open(a_path, 'wb') as f:
                f.write(audio_bytes)
            with open(i_path, 'wb') as f:
                f.write(image_bytes)
            cmd = [
                'ffmpeg', '-y',
                '-loop', '1', '-i', i_path,
                '-i', a_path,
                '-c:v', 'libx264', '-tune', 'stillimage', '-pix_fmt', 'yuv420p',
                '-c:a', 'aac', '-b:a', '192k',
                '-shortest',
                '-vf', 'scale=1280:720:force_original_aspect_ratio=decrease,'
                       'pad=1280:720:(ow-iw)/2:(oh-ih)/2:color=white',
                v_path,
            ]
            t0 = time.time()
            proc = subprocess.run(
                cmd, capture_output=True, check=False, timeout=180,
            )
            elapsed = time.time() - t0
            if proc.returncode != 0:
                err = (proc.stderr or b'').decode('utf-8', errors='replace')[-500:]
                raise RuntimeError(f'ffmpeg failed: {err}')
            with open(v_path, 'rb') as f:
                video_bytes = f.read()
        attach = _attach_bytes(
            self.env,
            name=f'plan-{plan.id}-week-{material.week_number}'
                 f'-{material.material_type}-{material.id}.mp4',
            mime_type='video/mp4',
            data=video_bytes,
            res_model='encoach.course.plan.media',
            res_id=media.id,
        )
        media.write({
            'attachment_id': attach.id,
            'mime_type': 'video/mp4',
            'size_bytes': len(video_bytes),
            'duration_seconds': float(audio.duration_seconds or elapsed or 0),
            'width': 1280,
            'height': 720,
            'provider': 'ffmpeg',
            'status': 'ready',
            'error': False,
        })
        return media

    def _compose_video_static(self, media, material):
        """Last-resort: a tiny MP4-shaped image-only stub.

        We don't pretend to render a true video without ffmpeg — instead
        we attach the placeholder PNG with an MP4 mime so the LMS can
        still display *something*. The media row is marked ``ready`` but
        flagged as ``static`` so admins can re-generate later.
        """
        from odoo.addons.encoach_ai.services.free_image import (
            render_placeholder,
        )
        plan = material.plan_id
        png = render_placeholder(
            material.title or 'Course material',
            subtitle=_image_subtitle(material, plan=plan) + ' · static',
            size='1280x720',
            seed=material.id,
        )
        attach = _attach_bytes(
            self.env,
            name=f'plan-{plan.id}-week-{material.week_number}'
                 f'-{material.material_type}-{material.id}-static.png',
            mime_type='image/png',
            data=png,
            res_model='encoach.course.plan.media',
            res_id=media.id,
        )
        media.write({
            'attachment_id': attach.id,
            'mime_type': 'image/png',
            'size_bytes': len(png),
            'duration_seconds': 0.0,
            'width': 1280,
            'height': 720,
            'provider': 'static',
            'status': 'ready',
            'error': 'ffmpeg not available — served as static placeholder image',
        })
        return media
