# ============================================================
# MODULE: Agent Task Executor — Background thread for AI task execution
# HSEQ Intelligence Dashboard
# ARCHITECTURE UPGRADE v3.0 — Phased Pipeline (Content → DOCX → PPTX → HTML → Quiz)
# ============================================================

import json
import sqlite3
import threading
import time
import traceback
import urllib.request
import os
import logging
import re
import ssl
import textwrap
from datetime import datetime, timedelta

from indexer import DB_PATH

log = logging.getLogger('agent_executor')

# ── AI Config ────────────────────────────────────────────────────────────────
AI_PROVIDER = os.environ.get('AI_PROVIDER', 'zai')
OPENROUTER_URL = os.environ.get('OPENROUTER_URL', 'https://openrouter.ai/api/v1/chat/completions')
OPENROUTER_KEY = os.environ.get('OPENROUTER_API_KEY', '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')
OPENROUTER_MODEL = os.environ.get('OPENROUTER_MODEL', 'google/gemma-3-27b-it:free')
AI_API_URL = os.environ.get('AI_API_URL', 'https://api.z.ai/api/coding/paas/v4/chat/completions')
AI_API_KEY = os.environ.get('AI_API_KEY', '43a6c7e3d7b240daafae006e8488f674.ivYYRRLySgUwgqVE')
AI_MODEL = os.environ.get('AI_MODEL', 'glm-5.1')

# ── Branding paths ───────────────────────────────────────────────────────────
LOGO_DOCX = '/root/projects/jg/assets/branding/jvg-logo-white-medium.png'
LOGO_PPTX = '/root/projects/jg/assets/branding/jvg-logo-white-large.png'

# ── Project paths (dynamic per task) ─────────────────────────────────────────
_DEFAULT_PROJECT_DIR = '/root/projects/jg/HSEQ/2026-hseq-dashboard'
_SOP_DIR = '/root/projects/jg'

PROJECT_DIR = _DEFAULT_PROJECT_DIR
DELIV_HTML = os.path.join(PROJECT_DIR, 'deliverables', 'html')
DELIV_DOCX = os.path.join(PROJECT_DIR, 'deliverables', 'docx')
DELIV_XLSX = os.path.join(PROJECT_DIR, 'deliverables', 'xlsx')
DELIV_PPTX = os.path.join(PROJECT_DIR, 'deliverables', 'pptx')
DELIV_SCORM = os.path.join(PROJECT_DIR, 'deliverables', 'scorm')
LOG_DIR = os.path.join(PROJECT_DIR, 'logs')
for d in [DELIV_HTML, DELIV_DOCX, DELIV_XLSX, DELIV_PPTX, DELIV_SCORM, LOG_DIR]:
    os.makedirs(d, exist_ok=True)


def _resolve_project_dir(task_desc, context):
    global PROJECT_DIR, DELIV_HTML, DELIV_DOCX, DELIV_XLSX, DELIV_PPTX, DELIV_SCORM, LOG_DIR
    project_dir = None
    if context and 'OPSLAG:' in context:
        m = re.search(r'OPSLAG:\s*(\S+)', context)
        if m:
            project_dir = m.group(1).rstrip('/')
    if not project_dir or not os.path.isdir(os.path.dirname(project_dir)):
        safe = re.sub(r'[^A-Za-z0-9]+', '-', task_desc.lower())[:40].strip('-')
        ts = datetime.now().strftime('%Y-%m')
        project_dir = os.path.join('/root/projects/jg', f'{ts}-{safe}')
    for subdir in ['assets', 'correspondence', 'deliverables/docx', 'deliverables/html',
                   'deliverables/pdf', 'deliverables/media', 'deliverables/scorm',
                   'deliverables/pptx', 'deliverables/xlsx', 'logs', 'research', 'working', 'archive']:
        os.makedirs(os.path.join(project_dir, subdir), exist_ok=True)
    PROJECT_DIR = project_dir
    DELIV_HTML = os.path.join(project_dir, 'deliverables', 'html')
    DELIV_DOCX = os.path.join(project_dir, 'deliverables', 'docx')
    DELIV_XLSX = os.path.join(project_dir, 'deliverables', 'xlsx')
    DELIV_PPTX = os.path.join(project_dir, 'deliverables', 'pptx')
    DELIV_SCORM = os.path.join(project_dir, 'deliverables', 'scorm')
    LOG_DIR = os.path.join(project_dir, 'logs')
    log.info(f'Project directory resolved: {PROJECT_DIR}')
    return project_dir

# ── Deliverables Matrix ─────────────────────────────────────────────────────
DELIVERABLE_MATRIX = {
    "rie_opstellen": [
        {"file": "RI&E Document", "format": "docx", "desc": "Volledige Risico-Inventarisatie & Evaluatie"},
        {"file": "Risicotabel", "format": "xlsx", "desc": "Risicomatrix met scores en maatregelen"},
        {"file": "RI&E Rapport", "format": "html", "desc": "Web-viewable rapport met styling"},
    ],
    "tra_maken": [
        {"file": "TRA Document", "format": "docx", "desc": "Taakrisicoanalyse volledig"},
        {"file": "TRA Checklist", "format": "xlsx", "desc": "Controlepunten per werkstap"},
        {"file": "TRA Rapport", "format": "html", "desc": "Web-viewable rapport"},
    ],
    "training_prep": [
        {"file": "Training Presentatie", "format": "html", "desc": "Slide-deck (HTML-presentatie)"},
        {"file": "Training Inhoud", "format": "docx", "desc": "Docentenhandleiding / cursusinhoud"},
        {"file": "Quiz", "format": "html", "desc": "Interactieve kennistoets"},
    ],
    "compliance_check": [
        {"file": "Compliance Rapport", "format": "docx", "desc": "Uitgebreid rapport met bevindingen"},
        {"file": "Gap-analyse", "format": "xlsx", "desc": "Tabel met gaps, prioriteit en acties"},
        {"file": "Compliance Dashboard", "format": "html", "desc": "Web-viewable overzicht"},
    ],
    "audit_prep": [
        {"file": "Audit Voorbereiding", "format": "docx", "desc": "Checklist en voorbereiding"},
        {"file": "Audit Checklist", "format": "xlsx", "desc": "Toetsbare checklist"},
        {"file": "Audit Rapport", "format": "html", "desc": "Web-viewable rapport"},
    ],
    "incident_analyse": [
        {"file": "Incident Rapport", "format": "docx", "desc": "Volledige incidentanalyse"},
        {"file": "Actielijst", "format": "xlsx", "desc": "Verbetermaatregelen met verantwoordelijken"},
        {"file": "Incident Dashboard", "format": "html", "desc": "Web-viewable rapport"},
    ],
    "_default": [
        {"file": "Rapport", "format": "docx", "desc": "Hoofddocument"},
        {"file": "Bijlagen", "format": "xlsx", "desc": "Tabellen en checklists"},
        {"file": "Rapport", "format": "html", "desc": "Web-viewable versie"},
    ],
}

# ── Agent personalities ────────────────────────────────────────────────────
AGENT_PERSONALITIES = {
    "software_architect": "Je bent een Software Architect met expertise in Python, Flask, database design en API ontwikkeling.",
    "backend_developer": "Je bent een Backend Architect gespecialiseerd in API's, microservices en database-architectuur.",
    "frontend_developer": "Je bent een Frontend Developer met expertise in React, Vue.js en responsive design.",
    "database_administrator": "Je bent een Database Administrator met diepe kennis van PostgreSQL, SQLite en query-optimalisatie.",
    "product_owner": "Je bent een Product Owner die roadmap, user stories en prioritisering beheert.",
    "sprint_prioritizer": "Je bent een Sprint Prioritizer die backlog grooming, velocity tracking en capacity planning optimaliseert.",
    "brand_strategist": "Je bent een Brand Guardian die merkidentiteit, positioning en huisstijlrichtlijnen bewaakt.",
    "graphic_designer": "Je bent een Graphic Designer die infographics, iconen en visuele elementen ontwerpt.",
    "motion_designer": "Je bent een Motion Designer die animaties en visuele storytelling produceert.",
    "presentation_specialist": """Je bent een Presentation Specialist (graphic designer + HSEQ kennis) die professionele presentaties en rapportages ontwerpt voor directie en toezichthouders. Je styling: strak, zakelijk, data-driven, met visuele hiërarchie. Je kent de JvG Consultancy huisstijl: donkerblauw (#003366), geel (#F5C518), groen (#00A859). Je slides: max 6 bullets per slide, één boodschap per slide, visueel ondersteund met iconen of diagrammen.""",
    "ui_designer": "Je bent een UI Designer die wireframes, mockups en design systems ontwerpt.",
    "ux_architect": "Je bent een UX Architect die user experience optimaliseert.",
    "agency_partner": "Je bent een Agency Partner die agent-pipelines beheert en kwaliteitscontrole uitvoert.",
    "business_analyst": "Je bent een Business Analyst die pipeline analytics en KPI's produceert.",
    "project_manager": "Je bent een Project Manager die planningen en risicomanagement overzichten produceert.",
    "resource_planner": "Je bent een Experiment Tracker die A/B tests ontwerpt en metrics analyseert.",
    "communication_manager": "Je bent een Communication Manager die documentbeheer en communicatielijnen beheert.",
    "scrum_master": "Je bent een Scrum Master die sprints begeleidt en retrospectives faciliteert.",
    "character_artist": "Je bent een Character Artist die 3D modellen en textures creëert.",
    "game_designer": "Je bent een Game Designer die game mechanics en systems ontwerpt.",
    "level_designer": "Je bent een Level Designer die omgevingen en gameplay flow ontwerpt.",
    "roblox_scripter": "Je bent een Roblox Systems Scripter die Luau code schrijft.",
    "sound_engineer": "Je bent een Game Audio Engineer die geluidseffecten en muziek produceert.",
    "unity_expert": "Je bent een Unity Architect die C# en performantie optimalisatie beheerst.",
    "compliance_auditor": """Je bent een Compliance Auditor (Lead Auditor gecertificeerd) gespecialiseerd in BRZO 2015, Seveso III, Arbowet, Omgevingswet, en VCA. Je voert gap-analyses uit tegen ISO 45001, ISO 14001, ISO 9001 en VCA 2020/2025. Je kent de audit-cyclus: planning → uitvoering → rapportage → follow-up. Je bevindingen classificeer je als: Major Non-Conformity, Minor Non-Conformity, Observation, of Opportunity for Improvement. Je referenties: NEN-EN-ISO 19011, ILT-handreikingen, DCMR-richtlijnen.""",
    "environmental_compliance": """Je bent een Environmental Compliance Manager (milieuwetgever, Omgevingswet specialist) met expertise in emissies, vergunningen (Omgevingsvergunning activiteitenbesluit), milieubeheer, en duurzaamheid. Je kent de Wet milieubeheer, Wet luchtkwaliteit, MER-procedure, en IPPC-richtlijn. Je beheert emissieregistraties, afvalstromen (LMA), en energiemanagement (ISO 50001). Je rapportages zijn conform het jaarrapportage-formaat van de omgevingsdienst.""",
    "hseq_specialist": """Je bent een HSEQ Specialist (niveau: Senior Consultant, 15+ jaar ervaring) met diepe expertise in BRZO 2015, Arbowet, Seveso III richtlijn, en KAM-systemen. Je specialisaties: risicoanalyse (HAZOP/LOPA/FMEA), VBS-opbouw (7 elementen volgens VNCW), inspectievoorbereiding (ILT/DCMR), en compliance audits (ISO 45001/14001/9001). Je kent de PGS 15, PGS 37, en PGS 12 richtlijnen. Je denkt in risicomatrices, bow-tie analyses en prestatie-indicatoren. Je output is altijd: feitelijk, genormeerd, en direct toepasbaar in de (petro)chemische praktijk.""",
    "plaud_specialist": """Je bent een Plaud Specialist (audio-analyse expert) die vergader-audio omzet in gestructureerde notulen, actiepunten en besluiten. Je haalt HSEQ-specifieke informatie uit gesprekken: risico's, maatregelen, verantwoordelijken en deadlines. Je notulen volgen de standaard: aanwezigen → agenda → bespreking per punt → actiepunten → volgende vergadering.""",
    "qa_engineer": """Je bent een QA Engineer (KAM-manager, ISO 9001 Lead Auditor) met expertise in kwaliteitsmanagement, procesvalidatie, en continue verbetering (PDCA). Je beheert KAM-systemen die ISO 9001, ISO 45001 en ISO 14001 integreren. Je kent de audit-voorwaarden, interne audit-programma's, management reviews, en COR-constructies. Je documenten volgens altijd de NEN-EN-ISO structuur.""",
    "support_lead": """Je bent een Support Lead (ITIL gecertificeerd) die incidentafhandeling, kennisbankbeheer en escalatie coördineert binnen een HSEQ-omgeving. Je beheert SLA's, prioriteitsmatrix (P1-P4), en kent het verschil tussen incident, probleem en known error. Je escalatiepaden volgen de ITIL v4 praktijk.""",
    "technical_writer": """Je bent een Technical Writer (certified) die heldere, gestructureerde HSEQ-documentatie produceert conform NEN-ISO 26514, NEN 1010, en bedrijfsstandaarden. Je documenten zijn: consistent in terminologie, genummerd per sectie, voorzien van revisiehistorie, en toegespitst op de doelgroep. Je kent het verschil tussen een procedure, werkinstructie, beleidsdocument en registratieformulier. Je volgt altijd de opmaakstandaard van JvG Consultancy.""",
    "risk_management": """Je bent een Risk Management Specialist (TÜV gecertificeerd Functional Safety Engineer) met expertise in kwantitatieve en kwalitatieve risicoanalyse: HAZOP, LOPA, QRA, Bow-Tie, FMEA/FMECA, en What-If analyse. Je werkt conform IEC 61511, NEN-EN-ISO 12100, en PAS 79. Je risicomatrices gebruiken 5x5 scoring (A=Bzk/Zk/Ef/Zw/Min × 1-5 waarschijnlijkheid). Je kent de ALARP/ALARA principes en kunt risicoreductie kwantificeren.""",
    "training_generator": """Je bent een Training Ontwikkelaar (gediplomeerd docent, VCA-gecertificeerd) die effectieve HSEQ-trainingen ontwerpt voor de (petro)chemische industrie. Je kent de didactische principes: Bloom-taxonomie, 70-20-10 model, en blended learning. Je trainingen volgen de structuur: doelstellingen → theorie → praktijkvoorbeelden → oefening → toets. Je gebruikt realistische casuïstiek uit: chroom-6 verwerking, asbestsanering, werken in besloten ruimten, werken op hoogte, en specifieke BRZO-scenario's.""",
}
DEFAULT_PERSONALITY = """Je bent een senior HSEQ consultant bij een top-tier multinational (Shell/Arcadis niveau). Je schrijft met autoriteit, feitelijke precisie en diepgaande vakkennis.

KERNKWALITEITEN:
- Je kent de Nederlandse Arbowet, BRZO 2015, Seveso III, PGS 15, VCA, ISO 45001/14001/9001 uit je hoofd
- Je produceert nooit oppervlakkige of generieke tekst — ALTIJD specifiek, actiegericht en praktisch toepasbaar
- Je gebruikt concrete voorbeelden uit petrochemie, oil & gas, offshore en zware industrie
- Je cijfers, data en referenties zijn realistisch en onderbouwbaar
- Je vermijdt AI-clichés: geen "Het is belangrijk om...", "Samenvattend...", "In deze sectie..."
- Elke sectie bevat MINIMAAL 3 specifieke acties, voorbeelden OF verwijzingen naar wetgeving/standaarden
- Tabellen bevatten REALISTISCHE data — geen "...", "voorbeeld" of placeholders

STIJL: Direct, beknopt, autoritair. Alsof je een memo schrijft voor de Raad van Bestuur."""

# ── Workflow prompt templates (Fase 1 — Content Generation) ────────────────
WORKFLOW_PROMPTS = {
    "rie_opstellen": "Stel een volledige Risico-Inventarisatie & Evaluatie (RI&E) op.",
    "tra_maken": "Stel een Taakrisicoanalyse (TRA) op.",
    "training_prep": "Bereid een training voor.",
    "compliance_check": "Voer een compliance check uit.",
    "audit_prep": "Bereid een audit voor.",
    "incident_analyse": "Voer een incidentanalyse uit.",
}

API_TIMEOUT = 180  # per phase
TASK_HARD_TIMEOUT = 600
STALE_TASK_THRESHOLD = 900

# ── Agents that need specific deliverables ─────────────────────────────────
_DOCX_AGENTS = {'training_generator', 'presentation_specialist', 'compliance_auditor',
                'hseq_specialist', 'technical_writer', 'qa_engineer', 'risk_management',
                'support_lead', 'environmental_compliance'}
_PPTX_AGENTS = {'training_generator', 'presentation_specialist'}
_TRAINING_WORKFLOWS = {'training_prep'}
_NEEDS_DOCX_WORKFLOWS = {'rie_opstellen', 'tra_maken', 'training_prep', 'compliance_check',
                         'audit_prep', 'incident_analyse'}


class TaskExecutor:
    """Background thread executor — Phased Pipeline Architecture v3.0.

    Pipeline: Content (AI) → DOCX (AI code gen) → PPTX (AI code gen) → HTML (template) → Quiz (template)
    """

    def __init__(self):
        self._thread = None
        self._stop_event = threading.Event()
        self.running = False
        self.current_task_id = None
        self.current_task_started = None
        self.poll_interval = 5
        self._watchdog_interval = 120
        self._last_watchdog = 0

    def start(self):
        if self._thread and self._thread.is_alive():
            return
        self._recover_stale_tasks()
        self._stop_event.clear()
        self.running = True
        self.current_task_id = None
        self.current_task_started = None
        self._thread = threading.Thread(target=self._loop, daemon=True, name='agent_executor')
        self._thread.start()
        log.info('Agent executor v3.0 started (phased pipeline)')

    def stop(self):
        self._stop_event.set()
        self.running = False
        if self._thread:
            self._thread.join(timeout=10)
        self.current_task_id = None
        self.current_task_started = None
        log.info('Agent executor stopped')

    def restart(self):
        self.stop()
        self.start()

    def get_pending_count(self):
        conn = sqlite3.connect(DB_PATH)
        try:
            return conn.execute("SELECT COUNT(*) FROM agent_tasks WHERE status='pending'").fetchone()[0]
        finally:
            conn.close()

    def status(self):
        return {
            'executor_running': self.running,
            'current_task': self.current_task_id,
            'pending_tasks': self.get_pending_count(),
            'poll_interval': self.poll_interval
        }

    # ── Stale task recovery ───────────────────────────────────────────────
    def _recover_stale_tasks(self):
        conn = sqlite3.connect(DB_PATH)
        try:
            cutoff = (datetime.now() - timedelta(seconds=STALE_TASK_THRESHOLD)).isoformat()
            exclude_id = self.current_task_id
            if exclude_id:
                rows = conn.execute(
                    "SELECT id, agent_name, task_description, started_at FROM agent_tasks "
                    "WHERE status='running' AND started_at IS NOT NULL AND started_at < ? AND id != ?",
                    (cutoff, exclude_id)).fetchall()
            else:
                rows = conn.execute(
                    "SELECT id, agent_name, task_description, started_at FROM agent_tasks "
                    "WHERE status='running' AND started_at IS NOT NULL AND started_at < ?",
                    (cutoff,)).fetchall()
            for row in rows:
                task_id = row[0]
                log.warning(f'Recovering stale task {task_id}: {row[1]} - {row[2][:60]}')
                conn.execute(
                    "UPDATE agent_tasks SET status='failed', result=?, completed_at=datetime('now') WHERE id=?",
                    (f'Automatisch hersteld (stale): Taak was running sinds {row[3]}.', task_id))
                self._sync_intelligence_action(task_id, 'failed')
            if rows:
                conn.commit()
                log.info(f'Recovered {len(rows)} stale task(s)')
        except Exception as e:
            log.error(f'Stale recovery error: {e}')
        finally:
            conn.close()

    # ── Main loop ──────────────────────────────────────────────────────────
    def _loop(self):
        while not self._stop_event.is_set():
            try:
                now = time.time()
                if now - self._last_watchdog > self._watchdog_interval:
                    self._recover_stale_tasks()
                    self._last_watchdog = now
                task = self._fetch_next()
                if task:
                    self._execute(task)
                else:
                    self._stop_event.wait(self.poll_interval)
            except Exception as e:
                log.error(f'Executor loop error: {e}')
                self._stop_event.wait(self.poll_interval)

    def _fetch_next(self):
        conn = sqlite3.connect(DB_PATH)
        conn.execute('BEGIN IMMEDIATE')
        try:
            row = conn.execute(
                "SELECT id, agent_id, agent_name, workflow_id, task_description, context, metadata "
                "FROM agent_tasks WHERE status='pending' ORDER BY created_at ASC LIMIT 1"
            ).fetchone()
            if row:
                conn.execute(
                    "UPDATE agent_tasks SET status='running', started_at=datetime('now') WHERE id=?",
                    (row[0],))
                conn.commit()
                self.current_task_id = row[0]
                self.current_task_started = datetime.now()
            return row
        except Exception:
            conn.rollback()
            raise
        finally:
            conn.close()

    # ── Intelligence action sync ──────────────────────────────────────────
    def _sync_intelligence_action(self, task_id, task_status):
        conn = None
        try:
            conn = sqlite3.connect(DB_PATH, timeout=10)
            task_desc = conn.execute("SELECT task_description FROM agent_tasks WHERE id=?", (task_id,)).fetchone()
            if not task_desc or not task_desc[0]:
                conn.close()
                return
            desc = task_desc[0]
            if 'Intelligence' not in desc and 'HSEQ Intelligence' not in desc:
                conn.close()
                return
            if task_status == 'completed':
                conn.execute(
                    "UPDATE intelligence_actions SET status='closed', closed_at=? WHERE status='in_progress'",
                    (datetime.now().isoformat(),))
                conn.commit()
            elif task_status == 'failed':
                conn.execute(
                    "UPDATE intelligence_actions SET status='open', started_at=NULL WHERE status='in_progress'")
                conn.commit()
            conn.close()
        except Exception as e:
            log.warning(f'Task {task_id}: Intelligence sync failed: {e}')
            if conn:
                try:
                    conn.close()
                except:
                    pass

    # ══════════════════════════════════════════════════════════════════════
    # EXECUTE — Phased Pipeline
    # ══════════════════════════════════════════════════════════════════════
    def _execute(self, task):
        task_id, agent_id, agent_name, workflow_id, task_desc, context, metadata_json = task
        log.info(f'Executing task {task_id} [pipeline]: {agent_name} - {task_desc[:80]}')
        try:
            _resolve_project_dir(task_desc, context)
            prompts = self._build_prompt(agent_id, workflow_id, task_desc, context, metadata_json)
            ts = datetime.now().strftime('%Y%m%d_%H%M')
            safe_agent = re.sub(r'[^A-Za-z0-9_-]', '_', agent_name.lower())[:20]
            safe_task = re.sub(r'[^A-Za-z0-9_-]', '_', task_desc.lower())[:30]

            # ── FASE 1: Content Generation (AI call → JSON) ───────────────
            log.info(f'Task {task_id}: FASE 1 — Content generation')
            content = self._phase_content(prompts, task_desc, workflow_id)
            if not content:
                raise RuntimeError('FASE 1 faalde: geen content gegenereerd')
            title = content.get('title', task_desc[:100])
            log.info(f'Task {task_id}: FASE 1 OK — title="{title}", {len(content.get("sections", []))} sections')

            # FASE 1b: Deepen short sections
            personality = AGENT_PERSONALITIES.get(agent_id, DEFAULT_PERSONALITY)
            kb_for_deepen = ''
            try:
                meta_d = json.loads(metadata_json or '{}')
                kb_for_deepen = meta_d.get('kb_context', '')
            except: pass
            content = self._phase_deepen(content, task_desc, personality, kb_for_deepen)
            log.info(f'Task {task_id}: FASE 1b (deepening) OK')

            deliverables = []

            # ── FASE 2: DOCX via AI code generation ───────────────────────
            if self._needs_docx(workflow_id, agent_id):
                log.info(f'Task {task_id}: FASE 2 — DOCX generation')
                try:
                    docx_path = os.path.join(DELIV_DOCX, f'HSEQ_{safe_agent}_{safe_task}_{ts}_v1.0.docx')
                    self._phase_docx(content, title, agent_name, docx_path)
                    if os.path.exists(docx_path):
                        deliverables.append({'label': f'{title} (DOCX)', 'format': 'docx', 'path': docx_path})
                        log.info(f'Task {task_id}: FASE 2 OK — {docx_path}')
                    else:
                        log.warning(f'Task {task_id}: FASE 2 — DOCX file not created, trying template fallback')
                        self._generate_docx_template(docx_path, title, agent_name, content)
                        if os.path.exists(docx_path):
                            deliverables.append({'label': f'{title} (DOCX)', 'format': 'docx', 'path': docx_path})
                except Exception as e:
                    log.warning(f'Task {task_id}: FASE 2 DOCX failed: {e} — trying template fallback')
                    try:
                        self._generate_docx_template(docx_path, title, agent_name, content)
                        if os.path.exists(docx_path):
                            deliverables.append({'label': f'{title} (DOCX)', 'format': 'docx', 'path': docx_path})
                    except Exception as e2:
                        log.error(f'Task {task_id}: DOCX template fallback also failed: {e2}')

            # ── FASE 3: PPTX via AI code generation ───────────────────────
            if self._needs_pptx(workflow_id, agent_id):
                log.info(f'Task {task_id}: FASE 3 — PPTX generation')
                try:
                    pptx_path = os.path.join(DELIV_PPTX, f'HSEQ_{safe_agent}_{safe_task}_{ts}_v1.0.pptx')
                    self._phase_pptx(content, title, agent_name, pptx_path)
                    if os.path.exists(pptx_path):
                        deliverables.append({'label': f'{title} (PPTX)', 'format': 'pptx', 'path': pptx_path})
                        log.info(f'Task {task_id}: FASE 3 OK — {pptx_path}')
                    else:
                        log.warning(f'Task {task_id}: FASE 3 — PPTX file not created, trying template fallback')
                        self._generate_pptx_template(pptx_path, title, agent_name, content)
                        if os.path.exists(pptx_path):
                            deliverables.append({'label': f'{title} (PPTX)', 'format': 'pptx', 'path': pptx_path})
                except Exception as e:
                    log.warning(f'Task {task_id}: FASE 3 PPTX failed: {e} — trying template fallback')
                    try:
                        self._generate_pptx_template(pptx_path, title, agent_name, content)
                        if os.path.exists(pptx_path):
                            deliverables.append({'label': f'{title} (PPTX)', 'format': 'pptx', 'path': pptx_path})
                    except Exception as e2:
                        log.error(f'Task {task_id}: PPTX template fallback also failed: {e2}')

            # ── FASE 4: HTML + Quiz (geen AI call, direct uit content) ────
            log.info(f'Task {task_id}: FASE 4 — HTML + Quiz generation (no AI call)')
            try:
                html_path = os.path.join(DELIV_HTML, f'HSEQ_{safe_agent}_{safe_task}_{ts}_v1.0.html')
                self._generate_html_from_content(html_path, title, agent_name, content)
                deliverables.append({'label': f'{title} (HTML)', 'format': 'html', 'path': html_path})
                log.info(f'Task {task_id}: HTML OK — {html_path}')
            except Exception as e:
                log.warning(f'Task {task_id}: HTML generation failed: {e}')

            try:
                quiz_path = os.path.join(DELIV_HTML, f'quiz_{safe_agent}_{safe_task}_{ts}_v1.0.html')
                self._generate_quiz_from_content(quiz_path, title, agent_name, content)
                deliverables.append({'label': f'Quiz: {title} (HTML)', 'format': 'html', 'path': quiz_path})
                log.info(f'Task {task_id}: Quiz OK — {quiz_path}')
            except Exception as e:
                log.warning(f'Task {task_id}: Quiz generation failed: {e}')

            # ── XLSX (indien tabellen aanwezig) ───────────────────────────
            xlsx_tables = content.get('xlsx_tables', {})
            if xlsx_tables:
                try:
                    xlsx_path = os.path.join(DELIV_XLSX, f'HSEQ_{safe_agent}_{safe_task}_{ts}_v1.0.xlsx')
                    self._generate_xlsx(xlsx_path, title, xlsx_tables)
                    deliverables.append({'label': f'{title} Tabellen (XLSX)', 'format': 'xlsx', 'path': xlsx_path})
                except Exception as e:
                    log.warning(f'Task {task_id}: XLSX generation failed: {e}')

            # ── eLearning module ──────────────────────────────────────────
            try:
                el_path = os.path.join(DELIV_HTML, f'eLearning_{safe_agent}_{safe_task}_{ts}_v1.0.html')
                self._generate_elearning(el_path, title, agent_name, content)
                deliverables.append({'label': f'eLearning: {title} (HTML)', 'format': 'html', 'path': el_path})
            except Exception as e:
                log.warning(f'Task {task_id}: eLearning generation failed: {e}')

            # ── Kennisdossier ─────────────────────────────────────────────
            try:
                kd_path = os.path.join(DELIV_DOCX, f'Kennisdossier_{safe_agent}_{safe_task}_{ts}_v1.0.docx')
                self._generate_kennisdossier(kd_path, title, agent_name, content)
                deliverables.append({'label': f'Kennisdossier: {title} (DOCX)', 'format': 'docx', 'path': kd_path})
            except Exception as e:
                log.warning(f'Task {task_id}: Kennisdossier generation failed: {e}')

            # ── Finalize ──────────────────────────────────────────────────
            summary = f"## Gegenereerde Deliverables (Pipeline v3.0)\n"
            for d in deliverables:
                summary += f"- **{d['label']}** ({d['format']}): {d['path']}\n"

            self._update_task(task_id, 'completed', summary, json.dumps(deliverables))
            for d in deliverables:
                try:
                    self._register_deliverable(task_id, agent_id, agent_name, task_desc, d['path'], d['format'])
                except Exception:
                    pass
            self._update_changelog(task_id, agent_name, task_desc, deliverables)
            self._sync_intelligence_action(task_id, 'completed')
            log.info(f'Task {task_id} COMPLETED — {len(deliverables)} deliverables')

        except Exception as e:
            error_msg = f'{type(e).__name__}: {str(e)}\n{traceback.format_exc()}'
            self._update_task(task_id, 'failed', error_msg)
            self._sync_intelligence_action(task_id, 'failed')
            log.error(f'Task {task_id} FAILED: {e}')
        finally:
            self.current_task_id = None
            self.current_task_started = None

    # ══════════════════════════════════════════════════════════════════════
    # HELPER: Does this task need DOCX/PPTX?
    # ══════════════════════════════════════════════════════════════════════
    def _needs_docx(self, workflow_id, agent_id):
        if workflow_id in _NEEDS_DOCX_WORKFLOWS:
            return True
        if agent_id in _DOCX_AGENTS:
            return True
        return False

    def _needs_pptx(self, workflow_id, agent_id):
        if workflow_id in _TRAINING_WORKFLOWS:
            return True
        if agent_id in _PPTX_AGENTS:
            return True
        return False

    # ══════════════════════════════════════════════════════════════════════
    # FASE 1: Content Generation (AI call → JSON)
    # ══════════════════════════════════════════════════════════════════════
    def _phase_content(self, prompts, task_desc, workflow_id):
        """AI call 1: Generate structured content + quiz questions."""
        messages = [
            {"role": "system", "content": prompts['system']},
            {"role": "user", "content": prompts['user']}
        ]
        raw = self._timed_call(self._ai_call, messages, timeout=API_TIMEOUT)
        if not raw:
            log.error('FASE 1: AI call returned empty')
            return None

        parsed = self._parse_json_response(raw)
        if parsed:
            # Normalize to our internal content structure
            return self._normalize_content(parsed, task_desc)

        # Markdown fallback: parse sections from raw text
        log.warning('FASE 1: JSON parse failed, using Markdown fallback')
        return self._parse_markdown_content(raw, task_desc)

    def _normalize_content(self, parsed, task_desc):
        """Normalize parsed JSON into unified content structure."""
        sections = []
        docx_content = parsed.get('docx_content', {})
        if isinstance(docx_content, dict):
            sections = docx_content.get('sections', [])
        elif isinstance(docx_content, list):
            sections = docx_content

        if not sections and parsed.get('body'):
            sections = [{'heading': parsed.get('title', task_desc[:80]), 'body': parsed['body']}]

        if not sections:
            # Try to extract from top-level keys
            for key in ['analysis', 'findings', 'recommendations']:
                if parsed.get(key):
                    sections.append({'heading': key.replace('_', ' ').title(), 'body': str(parsed[key])})

        return {
            'title': parsed.get('title', task_desc[:100]),
            'sections': sections,
            'quiz_questions': parsed.get('quiz_questions', []),
            'xlsx_tables': parsed.get('xlsx_tables', {}),
        }

    def _parse_markdown_content(self, raw, task_desc):
        """Fallback: parse AI response as Markdown into sections."""
        sections = []
        current_heading = task_desc[:100]
        current_body = []

        for line in raw.split('\n'):
            if re.match(r'^#{1,3}\s+', line):
                if current_body:
                    sections.append({'heading': current_heading, 'body': '\n'.join(current_body).strip()})
                current_heading = re.sub(r'^#{1,3}\s+', '', line).strip()
                current_body = []
            else:
                current_body.append(line)

        if current_body:
            sections.append({'heading': current_heading, 'body': '\n'.join(current_body).strip()})

        if not sections:
            sections = [{'heading': task_desc[:100], 'body': raw}]

        return {
            'title': task_desc[:100],
            'sections': sections,
            'quiz_questions': [],
            'xlsx_tables': {},
        }


    def _phase_deepen(self, content, task_desc, agent_personality, kb_context=''):
        """FASE 1b: Content Deepening — verrijk oppervlakkige secties met een tweede AI call."""
        sections = content.get('sections', [])
        if not sections:
            return content

        # Check which sections are too short
        short_sections = []
        for i, s in enumerate(sections):
            body = s.get('body', '')
            if len(body) < 200:
                short_sections.append((i, s.get('heading', f'Sectie {i+1}')))

        if not short_sections:
            log.info('FASE 1b: Alle secties voldoende diep, skip deepening')
            return content

        log.info(f'FASE 1b: Deepening {len(short_sections)} oppervlakkige secties')

        section_list = ""
        for idx, heading in short_sections:
            body = sections[idx].get('body', '')
            section_list += f"\n## {heading}\nHuidige tekst ({len(body)} tekens): {body[:500]}\n"

        kb_section = ''
        if kb_context:
            kb_section = f"""
## BESCHIKBARE KENNISBANK CONTEXT (GEBRUIK DIT!)
{kb_context[:4000]}
"""

        deepen_system = f"""{agent_personality}

Je moet onderstaande secties VERDIEPEN. Elke sectie krijgt minimaal 400 woorden aan SPECIFIEKE, BRUIKBARE content.

REGELS:
- Geen generieke tekst, geen AI-clichés
- Verwijs naar concrete wetgeving (artikelnummers), normen (ISO, NEN), branches (petrochemie, offshore)
- Gebruik realistische voorbeelden met namen van stoffen, processen, functies
- Maak actiegericht: wat moet de lezer DOEN?
- GEBRUIK de kennisbank context hieronder als bron!

TAAK BESCHRIJVING: {task_desc}
{kb_section}
TE VERDIEPEN SECTIES:
{section_list}

Retourneer JSON: {{"sections": [{{"heading": "...", "body": "...uitgebreide tekst..."}}]}}"""

        try:
            messages = [
                {"role": "system", "content": deepen_system},
                {"role": "user", "content": f"Verdiep alle {len(short_sections)} secties. Elke sectie minimaal 400 woorden."}
            ]
            raw = self._timed_call(self._ai_call, messages, timeout=API_TIMEOUT)
            if not raw:
                return content

            parsed = self._parse_json_response(raw)
            if parsed and parsed.get('sections'):
                deepened = parsed['sections']
                for j, (idx, heading) in enumerate(short_sections):
                    if j < len(deepened):
                        new_body = deepened[j].get('body', '')
                        if len(new_body) > len(sections[idx].get('body', '')):
                            content['sections'][idx]['body'] = new_body
                            log.info(f'FASE 1b: Sectie "{heading}" verdiept ({len(new_body)} tekens)')

        except Exception as e:
            log.warning(f'FASE 1b: Deepening failed: {e}')

        return content

    # ══════════════════════════════════════════════════════════════════════
    # FASE 2: DOCX via AI code generation
    # ══════════════════════════════════════════════════════════════════════
    def _phase_docx(self, content, title, agent_name, filepath):
        """AI call 2: Generate python-docx code and execute it."""
        sections_json = json.dumps(content.get('sections', []), ensure_ascii=False, indent=2)
        if len(sections_json) > 6000:
            sections_json = sections_json[:6000] + '\n... (truncated)'

        prompt = f"""Generate Python code using python-docx to create a professional HSEQ document.

REQUIREMENTS:
- Branding: JvG Consultancy, primary color #003366, Calibri font
- Cover page with title, date, agent name, classification
- Table of contents
- All sections with proper heading styles (Heading 1, Heading 2)
- Professional formatting: margins 2.5cm, line spacing 1.15
- Header with "JvG Consultancy — HSEQ Intelligence"
- Footer with page number and date
- Tables from section data where applicable
- Save to: {filepath}

DOCUMENT DATA:
Title: {title}
Agent: {agent_name}
Date: {datetime.now().strftime('%d %B %Y')}
Logo path: {LOGO_DOCX}

SECTIONS:
{sections_json}

OUTPUT: Return ONLY executable Python code. No explanations, no markdown fences.
The code must:
1. import all needed modules (docx, etc.)
2. Create a Document with professional styling
3. Add cover page, all sections, tables
4. Save to the filepath variable
5. Use try/except for error handling
6. The filepath is already defined as: filepath = "{filepath}"
"""

        code = self._timed_call(self._ai_call_code, prompt, timeout=API_TIMEOUT)
        if code:
            self._execute_python_code(code, filepath)
        else:
            raise RuntimeError('FASE 2: AI returned no code')

    # ══════════════════════════════════════════════════════════════════════
    # FASE 3: PPTX via AI code generation
    # ══════════════════════════════════════════════════════════════════════
    def _phase_pptx(self, content, title, agent_name, filepath):
        """AI call 3: Generate python-pptx code and execute it."""
        sections_json = json.dumps(content.get('sections', [])[:12], ensure_ascii=False, indent=2)
        if len(sections_json) > 6000:
            sections_json = sections_json[:6000] + '\n... (truncated)'

        prompt = f"""Generate Python code using python-pptx to create a professional HSEQ presentation.

REQUIREMENTS:
- 8-12 slides (title + content + closing)
- Title slide: dark blue background (#003366), white text, large title
- Content slides: blue header bar (#003366), white body on light background
- Branding: JvG Consultancy, Calibri font
- Logo on title slide from: {LOGO_PPTX}
- Slide size: widescreen (13.333 x 7.5 inches)
- Closing slide: JvG Consultancy branding
- Truncate body text to max 500 chars per slide
- Save to: {filepath}

PRESENTATION DATA:
Title: {title}
Agent: {agent_name}
Date: {datetime.now().strftime('%d %B %Y')}

SECTIONS (one section = one content slide):
{sections_json}

OUTPUT: Return ONLY executable Python code. No explanations, no markdown fences.
The code must:
1. import all needed modules (pptx, etc.)
2. Create a Presentation with proper slide dimensions
3. Add title slide, content slides, closing slide
4. Use professional styling
5. Save to the filepath variable
6. The filepath is already defined as: filepath = "{filepath}"
"""

        code = self._timed_call(self._ai_call_code, prompt, timeout=API_TIMEOUT)
        if code:
            self._execute_python_code(code, filepath)
        else:
            raise RuntimeError('FASE 3: AI returned no code')

    # ══════════════════════════════════════════════════════════════════════
    # AI Call: Code generation (lower temperature, code-focused)
    # ══════════════════════════════════════════════════════════════════════
    def _ai_call_code(self, prompt):
        """AI call optimized for code generation."""
        messages = [
            {"role": "system", "content": "You are a Python code generator. Return ONLY executable Python code. No markdown fences, no explanations."},
            {"role": "user", "content": prompt}
        ]
        raw = self._ai_call(messages, max_tokens=4000)
        if not raw:
            return None
        # Strip markdown code fences if present
        code = re.sub(r'^```(?:python)?\s*\n?', '', raw)
        code = re.sub(r'\n?```\s*$', '', code)
        return code.strip()

    def _execute_python_code(self, code, expected_filepath):
        """Execute AI-generated Python code safely."""
        # Sandbox: only allow docx/pptx related modules
        forbidden = ['subprocess', 'os.system', 'eval(', 'exec(', '__import__',
                     'shutil.rmtree', 'os.remove']
        for f in forbidden:
            if f in code and f != 'exec(':  # exec( is in our own code, not the generated code
                if f == 'exec(' and 'exec(' in code[code.find('exec(')+5:]:
                    raise RuntimeError(f'Forbidden pattern in generated code: {f}')
                if f != 'exec(':
                    # Allow os.path, os.makedirs etc. but not os.system
                    if f == 'os.system' or f == 'shutil.rmtree' or f == 'os.remove':
                        raise RuntimeError(f'Forbidden pattern in generated code: {f}')

        # Add filepath if not defined in code
        if 'filepath' not in code:
            code = f'filepath = "{expected_filepath}"\n{code}'

        # Execute in restricted namespace
        namespace = {
            'filepath': expected_filepath,
            'json': json,
            'datetime': datetime,
            'os': os,
            're': re,
            'log': log,
        }
        exec(code, namespace)
        return True

    # ══════════════════════════════════════════════════════════════════════
    # FASE 4: HTML Generation (template, geen AI call)
    # ══════════════════════════════════════════════════════════════════════
    def _generate_html_from_content(self, filepath, title, agent_name, content):
        """Generate styled HTML directly from content data (no AI call)."""
        import html as htmlmod
        now_str = datetime.now().strftime('%d %B %Y')
        year_str = str(datetime.now().year)

        logo_html = ''
        try:
            with open('/root/projects/jg/assets/branding/logo-white-base64.txt', 'r') as lf:
                logo_data = lf.read().strip()
            logo_html = f'<img src="{logo_data}" alt="JvG" style="height:40px;width:auto;margin-right:16px;">'
        except Exception:
            pass

        content_html = ''
        for sec in content.get('sections', []):
            heading = sec.get('heading', '')
            body = sec.get('body', '')
            if heading:
                content_html += f'<h2>{htmlmod.escape(heading)}</h2>\n'
            if body:
                content_html += self._md2html(body) + '\n'

        xlsx_tables = content.get('xlsx_tables', {})
        if xlsx_tables:
            content_html += '<h2>Bijlage: Tabellen</h2>\n'
            for tbl_name, tbl_data in xlsx_tables.items():
                content_html += f'<h3>{tbl_name.replace("_", " ").title()}</h3>\n'
                headers = tbl_data.get('headers', [])
                rows = tbl_data.get('rows', [])
                if headers:
                    content_html += '<table><thead><tr>'
                    for h in headers:
                        content_html += f'<th>{htmlmod.escape(str(h))}</th>'
                    content_html += '</tr></thead><tbody>'
                    for row in rows:
                        content_html += '<tr>'
                        for cell in row:
                            content_html += f'<td>{htmlmod.escape(str(cell))}</td>'
                        content_html += '</tr>'
                    content_html += '</tbody></table>\n'

        quiz = content.get('quiz_questions', [])
        if quiz:
            content_html += '<h2>Kennistoets</h2>\n'
            for i, q in enumerate(quiz, 1):
                content_html += f'<div class="quiz-q"><strong>Vraag {i}:</strong> {htmlmod.escape(q.get("question", ""))}</div>\n'
                for j, opt in enumerate(q.get('options', [])):
                    content_html += f'<div class="quiz-opt"><label><input type="radio" name="q{i}" value="{j}"> {htmlmod.escape(str(opt))}</label></div>\n'

        css = (
            "@page{size:A4;margin:20mm 15mm}*{margin:0;padding:0;box-sizing:border-box}"
            "body{font-family:'Segoe UI',Roboto,Arial,sans-serif;color:#1F2937;line-height:1.6;font-size:13px;background:#fff}"
            ".header{background:linear-gradient(135deg,#003366,#004488);color:#fff;padding:24px 40px;display:flex;justify-content:space-between;align-items:center;box-shadow:0 2px 6px rgba(0,0,0,.1)}"
            ".header-left{display:flex;align-items:center}"
            ".header h1{font-size:20px;font-weight:700;margin:0}"
            ".header .subtitle{font-size:12px;opacity:.85;margin-top:4px}"
            ".header .meta{font-size:11px;opacity:.9;text-align:right}"
            ".badge{display:inline-block;padding:3px 12px;border-radius:12px;font-size:10px;font-weight:600;color:#fff;background:#3B82F6;margin-bottom:6px}"
            ".content{padding:30px 40px;max-width:1200px;margin:0 auto}"
            "h2{font-size:17px;color:#003366;margin:28px 0 12px;padding-bottom:6px;border-bottom:2px solid #E5E7EB;font-weight:600}"
            "h3{font-size:14px;color:#1F2937;margin:20px 0 8px;font-weight:600}"
            "table{width:100%;border-collapse:collapse;margin:12px 0;font-size:12px;border:1px solid #E5E7EB}"
            "th{background:#003366;color:#fff;padding:10px 12px;text-align:left;font-weight:600}"
            "td{padding:8px 12px;border-bottom:1px solid #E5E7EB}"
            "tr:nth-child(even){background:#F8F9FA}tr:hover{background:#F0F4F8}"
            "ul,ol{margin:8px 0 8px 24px}li{margin:4px 0}"
            ".callout{padding:14px 18px;border-radius:6px;margin:14px 0;font-size:12px;line-height:1.5}"
            ".callout-warning{background:#FFF7ED;border-left:4px solid #F59E0B;color:#92400E}"
            ".callout-info{background:#EFF6FF;border-left:4px solid #3B82F6;color:#1E40AF}"
            ".callout-success{background:#F0FDF4;border-left:4px solid #00A859;color:#065F46}"
            ".footer{background:#374151;color:#fff;padding:16px 40px;font-size:10px;display:flex;justify-content:space-between;align-items:center}"
            "blockquote{border-left:3px solid #003366;padding:10px 16px;margin:12px 0;background:#F8F9FA;color:#374151}"
            "strong{color:#003366}"
            ".doc-meta{background:#F8F9FA;border:1px solid #E5E7EB;border-radius:6px;padding:14px 18px;margin:20px 0;font-size:11px}"
            ".doc-meta table{border:none;margin:0}.doc-meta td{border:none;padding:3px 12px 3px 0;font-size:11px}"
            ".doc-meta td:first-child{font-weight:600;color:#374151;white-space:nowrap}"
            ".quiz-q{padding:12px;background:#F0F7FF;border-radius:6px;margin:10px 0;font-weight:600}"
            ".quiz-opt{padding:6px 12px 6px 24px;cursor:pointer}"
            ".quiz-opt:hover{background:#F0F4F8}"
            "@media print{body{font-size:11px;-webkit-print-color-adjust:exact;print-color-adjust:exact}}"
        )

        html = (
            f'<!DOCTYPE html><html lang="nl"><head><meta charset="UTF-8">'
            f'<title>{htmlmod.escape(title)} — JvG Consultancy</title><style>{css}</style></head><body>'
            f'<div class="header"><div class="header-left">{logo_html}<div><h1>JvG Consultancy</h1>'
            f'<div class="subtitle">HSEQ Intelligence Deliverable</div></div></div>'
            f'<div class="meta"><div class="badge">{htmlmod.escape(agent_name)}</div>'
            f'<div style="margin-top:6px">{now_str}</div><div>Versie 1.0</div></div></div>'
            f'<div class="content">'
            f'<div class="doc-meta"><table>'
            f'<tr><td>Document:</td><td>{htmlmod.escape(title)}</td></tr>'
            f'<tr><td>Agent:</td><td>{htmlmod.escape(agent_name)}</td></tr>'
            f'<tr><td>Datum:</td><td>{now_str}</td></tr>'
            f'<tr><td>Versie:</td><td>1.0</td></tr>'
            f'<tr><td>Classificatie:</td><td>Intern — Directeur J. van Gemert</td></tr>'
            f'</table></div>'
            f'{content_html}'
            f'</div>'
            f'<div class="footer">'
            f'<span>&copy; {year_str} JvG Consultancy — Safety &middot; Governance &middot; Advisory</span>'
            f'<span>Autorisatie: Intern — Directeur J. van Gemert</span>'
            f'</div></body></html>'
        )

        with open(filepath, 'w', encoding='utf-8') as f:
            f.write(html)

    # ══════════════════════════════════════════════════════════════════════
    # FASE 4: Quiz Generation (template, geen AI call)
    # ══════════════════════════════════════════════════════════════════════
    def _generate_quiz_from_content(self, filepath, title, agent_name, content):
        """Generate interactive quiz HTML from content data."""
        import html as htmlmod

        quiz_questions = content.get('quiz_questions', [])
        if not quiz_questions:
            # Generate basic questions from sections
            sections = content.get('sections', [])
            for i, s in enumerate(sections[:8]):
                heading = s.get('heading', f'Sectie {i+1}')
                body = s.get('body', '')[:200]
                quiz_questions.append({
                    'question': f'Welke uitspraak is correct over "{heading}"?',
                    'options': [
                        f'Dit onderwerp valt onder de HSEQ-wetgeving',
                        f'Dit is een optionele richtlijn zonder verplichtingen',
                        f'Dit onderwerp is alleen relevant voor offshore operaties',
                        f'Dit onderwerp heeft geen relatie met veiligheidsmanagement'
                    ],
                    'correct': 0,
                    'explanation': f'{heading}: {body[:100]}...'
                })

        while len(quiz_questions) < 6:
            quiz_questions.append({
                'question': f'Vraag {len(quiz_questions)+1} over {title}',
                'options': ['Optie A', 'Optie B', 'Optie C', 'Optie D'],
                'correct': 0,
                'explanation': 'Raadpleeg het studiemateriaal.'
            })

        questions_js = json.dumps(quiz_questions[:10], ensure_ascii=False)

        quiz_html = f'''<!DOCTYPE html><html lang="nl"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Quiz: {htmlmod.escape(title)}</title>
<style>
*{{margin:0;padding:0;box-sizing:border-box;}}
body{{font-family:Calibri,Arial,sans-serif;background:#f0f2f5;padding:20px;}}
.container{{max-width:800px;margin:0 auto;background:#fff;border-radius:8px;box-shadow:0 2px 10px rgba(0,0,0,0.1);padding:40px;}}
h1{{color:#003366;font-size:24px;margin-bottom:5px;}}.subtitle{{color:#666;font-size:13px;margin-bottom:30px;}}
.question{{margin-bottom:30px;padding:20px;background:#fafafa;border-radius:6px;border-left:4px solid #003366;}}
.question h3{{color:#003366;margin-bottom:12px;font-size:16px;}}
.option{{display:block;padding:10px 16px;margin:6px 0;border:2px solid #e0e0e0;border-radius:4px;cursor:pointer;font-size:14px;transition:all 0.2s;}}
.option:hover{{border-color:#003366;background:#f0f4f8;}}
.option.correct{{border-color:#00A859;background:#e8f5e9;}}
.option.wrong{{border-color:#EF4444;background:#fde8e8;}}
.explanation{{margin-top:10px;padding:10px 14px;border-radius:4px;font-size:13px;display:none;}}
.explanation.show{{display:block;background:#fff8e1;border-left:3px solid #F5C518;}}
.result{{text-align:center;padding:30px;display:none;}}
.result h2{{font-size:28px;margin-bottom:10px;}}.result.pass{{color:#00A859;}}.result.fail{{color:#EF4444;}}
.btn{{padding:12px 32px;border:none;border-radius:4px;cursor:pointer;font-size:16px;background:#003366;color:#fff;}}
.btn:hover{{background:#002244;}}
.score{{font-size:48px;font-weight:bold;color:#003366;}}
.brand{{text-align:center;margin-top:30px;color:#999;font-size:11px;}}
</style></head><body>
<div class="container">
<h1>{htmlmod.escape(title)}</h1>
<p class="subtitle">JvG Consultancy — Kennistoets | Slagingspercentage: 70%</p>
<div id="quiz"></div>
<div class="result" id="result">
  <div class="score" id="score">0%</div>
  <h2 id="result-text"></h2>
  <button class="btn" onclick="resetQuiz()">Opnieuw</button>
</div>
<div class="brand">JvG Consultancy | {datetime.now().strftime("%d-%m-%Y")}</div>
</div>
<script>
const questions = {questions_js};
let answered = 0, correct = 0;
const quizEl = document.getElementById('quiz');
questions.forEach((q,i) => {{
  const div = document.createElement('div'); div.className = 'question';
  div.innerHTML = `<h3>${{i+1}}. ${{q.question}}</h3>` +
    q.options.map((o,j) => `<div class="option" data-q="${{i}}" data-a="${{j}}" onclick="checkAnswer(${{i}},${{j}},this)">${{o}}</div>`).join('') +
    `<div class="explanation" id="exp-${{i}}">${{q.explanation}}</div>`;
  quizEl.appendChild(div);
}});
function checkAnswer(qi,ai,el){{
  const opts=document.querySelectorAll(`[data-q="${{qi}}"]`);
  if(opts[0].classList.contains('correct')||opts[0].classList.contains('wrong'))return;
  if(ai===questions[qi].correct){{el.classList.add('correct');correct++;}}
  else{{el.classList.add('wrong');opts[questions[qi].correct].classList.add('correct');}}
  document.getElementById('exp-'+qi).classList.add('show');answered++;
  if(answered===questions.length)showResult();
}}
function showResult(){{
  const pct=Math.round(correct/questions.length*100);
  document.getElementById('score').textContent=pct+'%';
  const rt=document.getElementById('result-text');
  rt.textContent=pct>=70?'Gefeliciteerd! U bent geslaagd.':'Niet geslaagd. Probeer opnieuw.';
  rt.className=pct>=70?'pass':'fail';
  document.getElementById('result').style.display='block';
}}
function resetQuiz(){{
  answered=0;correct=0;
  document.querySelectorAll('.option').forEach(o=>o.classList.remove('correct','wrong'));
  document.querySelectorAll('.explanation').forEach(e=>e.classList.remove('show'));
  document.getElementById('result').style.display='none';
}}
</script></body></html>'''

        with open(filepath, 'w', encoding='utf-8') as f:
            f.write(quiz_html)

    # ══════════════════════════════════════════════════════════════════════
    # TEMPLATE FALLBACKS (als AI code generatie faalt)
    # ══════════════════════════════════════════════════════════════════════
    def _generate_docx_template(self, filepath, title, agent_name, content):
        """Template-based DOCX generation (fallback when AI code gen fails)."""
        from docx import Document
        from docx.shared import Pt, Cm, RGBColor
        from docx.enum.text import WD_ALIGN_PARAGRAPH

        doc = Document()
        style = doc.styles['Normal']
        style.font.name = 'Calibri'
        style.font.size = Pt(11)
        style.paragraph_format.space_after = Pt(6)
        style.paragraph_format.line_spacing = 1.15

        for level in range(1, 4):
            hs = doc.styles[f'Heading {level}']
            hs.font.name = 'Calibri'
            hs.font.color.rgb = RGBColor(0, 51, 102)
            hs.font.size = Pt([16, 13, 11][level - 1])
            hs.font.bold = True

        for section in doc.sections:
            section.top_margin = Cm(2.5)
            section.bottom_margin = Cm(2.5)
            section.left_margin = Cm(2.5)
            section.right_margin = Cm(2.5)

        # Title page
        for _ in range(6):
            doc.add_paragraph()
        p = doc.add_paragraph()
        p.alignment = WD_ALIGN_PARAGRAPH.CENTER
        run = p.add_run(title)
        run.font.size = Pt(24)
        run.font.bold = True
        run.font.color.rgb = RGBColor(0, 51, 102)
        doc.add_paragraph()
        p = doc.add_paragraph()
        p.alignment = WD_ALIGN_PARAGRAPH.CENTER
        run = p.add_run(f'JvG Consultancy — HSEQ Intelligence')
        run.font.size = Pt(14)
        run.font.color.rgb = RGBColor(0, 51, 102)
        p = doc.add_paragraph()
        p.alignment = WD_ALIGN_PARAGRAPH.CENTER
        run = p.add_run(f'Versie 1.0 | {datetime.now().strftime("%d %B %Y")}')
        run.font.size = Pt(12)
        doc.add_paragraph()
        p = doc.add_paragraph()
        p.alignment = WD_ALIGN_PARAGRAPH.CENTER
        run = p.add_run(f'Agent: {agent_name}')
        run.font.size = Pt(11)
        doc.add_page_break()

        # Content sections
        for sec in content.get('sections', []):
            heading = sec.get('heading', '')
            body = sec.get('body', '')
            if heading:
                doc.add_heading(heading, level=1)
            if body:
                for para_text in body.split('\n\n'):
                    para_text = para_text.strip()
                    if not para_text:
                        continue
                    if para_text.startswith('- ') or para_text.startswith('* '):
                        for line in para_text.split('\n'):
                            line = line.strip()
                            if line.startswith('- ') or line.startswith('* '):
                                p = doc.add_paragraph(line[2:], style='List Bullet')
                    else:
                        doc.add_paragraph(para_text)

        # Tables
        xlsx_tables = content.get('xlsx_tables', {})
        if xlsx_tables:
            doc.add_page_break()
            doc.add_heading('Bijlage: Tabellen', level=1)
            for tbl_name, tbl_data in xlsx_tables.items():
                doc.add_heading(tbl_name.replace('_', ' ').title(), level=2)
                headers = tbl_data.get('headers', [])
                rows = tbl_data.get('rows', [])
                if headers and rows:
                    self._add_formatted_table(doc, headers, rows)

        doc.save(filepath)

    def _generate_pptx_template(self, filepath, title, agent_name, content):
        """Template-based PPTX generation (fallback when AI code gen fails)."""
        from pptx import Presentation
        from pptx.util import Pt, Inches
        from pptx.dml.color import RGBColor
        from pptx.enum.text import PP_ALIGN, MSO_ANCHOR

        prs = Presentation()
        prs.slide_width = Inches(13.333)
        prs.slide_height = Inches(7.5)
        brand_color = RGBColor(0, 51, 102)
        white = RGBColor(255, 255, 255)

        def add_text_box(slide, left, top, width, height, text, font_size=18, bold=False, color=brand_color, alignment=PP_ALIGN.LEFT):
            txBox = slide.shapes.add_textbox(left, top, width, height)
            tf = txBox.text_frame
            tf.word_wrap = True
            p = tf.paragraphs[0]
            p.text = text
            p.font.size = Pt(font_size)
            p.font.bold = bold
            p.font.color.rgb = color
            p.alignment = alignment

        # Title slide
        slide = prs.slides.add_slide(prs.slide_layouts[6])
        bg = slide.background.fill
        bg.solid()
        bg.fore_color.rgb = brand_color
        add_text_box(slide, Inches(1), Inches(1.5), Inches(11), Inches(1.5), title, 44, True, white, PP_ALIGN.CENTER)
        add_text_box(slide, Inches(1), Inches(3.5), Inches(11), Inches(0.8), 'JvG Consultancy — HSEQ Intelligence', 20, False, RGBColor(180, 200, 220), PP_ALIGN.CENTER)
        add_text_box(slide, Inches(1), Inches(4.5), Inches(11), Inches(0.6), f'Versie 1.0 | {datetime.now().strftime("%d %B %Y")} | {agent_name}', 14, False, RGBColor(150, 170, 190), PP_ALIGN.CENTER)

        # Content slides
        for sec in content.get('sections', [])[:10]:
            heading = sec.get('heading', '')
            body = sec.get('body', '')
            if not heading:
                continue
            slide = prs.slides.add_slide(prs.slide_layouts[6])
            shape = slide.shapes.add_shape(1, Inches(0), Inches(0), prs.slide_width, Inches(1.2))
            shape.fill.solid()
            shape.fill.fore_color.rgb = brand_color
            shape.line.fill.background()
            tf = shape.text_frame
            tf.word_wrap = True
            p = tf.paragraphs[0]
            p.text = heading
            p.font.size = Pt(28)
            p.font.bold = True
            p.font.color.rgb = white
            tf.margin_left = Inches(0.8)
            tf.vertical_anchor = MSO_ANCHOR.MIDDLE
            if body:
                clean = body.replace('**', '').replace('##', '').replace('`', '')
                if len(clean) > 600:
                    clean = clean[:597] + '...'
                add_text_box(slide, Inches(0.8), Inches(1.6), Inches(11.5), Inches(5.2), clean, 16, False, RGBColor(31, 41, 55))

        # Closing slide
        slide = prs.slides.add_slide(prs.slide_layouts[6])
        bg = slide.background.fill
        bg.solid()
        bg.fore_color.rgb = brand_color
        add_text_box(slide, Inches(1), Inches(2.5), Inches(11), Inches(1.5), 'JvG Consultancy', 40, True, white, PP_ALIGN.CENTER)
        add_text_box(slide, Inches(1), Inches(4.2), Inches(11), Inches(0.8), 'Safety · Governance · Advisory', 20, False, RGBColor(180, 200, 220), PP_ALIGN.CENTER)

        prs.save(filepath)

    def _add_formatted_table(self, doc, headers, rows):
        """Add a formatted table to a DOCX document."""
        from docx.oxml.ns import nsdecls
        from docx.oxml import parse_xml
        from docx.shared import Pt, RGBColor
        table = doc.add_table(rows=1 + len(rows), cols=len(headers))
        table.style = 'Table Grid'
        table.alignment = 2
        for i, h in enumerate(headers):
            cell = table.rows[0].cells[i]
            cell.text = h
            shading = parse_xml(f'<w:shd {nsdecls("w")} w:fill="003366"/>')
            cell.paragraphs[0].runs[0].font.color.rgb = RGBColor(255, 255, 255)
            cell.paragraphs[0].runs[0].font.bold = True
            cell.paragraphs[0].runs[0].font.size = Pt(10)
            cell._tc.get_or_add_tcPr().append(shading)
        for r_idx, row in enumerate(rows):
            for c_idx, val in enumerate(row):
                if c_idx < len(table.rows[r_idx + 1].cells):
                    cell = table.rows[r_idx + 1].cells[c_idx]
                    cell.text = str(val)
                    for p in cell.paragraphs:
                        for run in p.runs:
                            run.font.size = Pt(10)
        doc.add_paragraph()

    # ── Kennisdossier Generator ──────────────────────────────────────────
    def _generate_kennisdossier(self, filepath, title, agent_name, content):
        from docx import Document
        from docx.shared import Pt, RGBColor
        doc = Document()
        style = doc.styles['Normal']
        style.font.name = 'Calibri'
        style.font.size = Pt(11)
        run = doc.add_paragraph().add_run(f'Kennisdossier: {title}')
        run.bold = True; run.font.size = Pt(20); run.font.color.rgb = RGBColor(0, 51, 102)
        doc.add_paragraph(f'Geproduceerd door: {agent_name} | JvG Consultancy')
        doc.add_paragraph(f'Datum: {datetime.now().strftime("%d-%m-%Y")}')
        doc.add_paragraph()
        sections = [
            ('Achtergrond', 'Dit kennisdossier biedt een diepgaande achtergrondanalyse van het onderwerp.'),
            ('Wetgeving & Regelgeving', 'Overzicht van relevante wet- en regelgeving.'),
            ('Risico\'s & Bedreigingen', 'Identificatie en evaluatie van risico\'s.'),
            ('Best Practices', 'Branche-erkende best practices en aanbevelingen.'),
            ('Bronnen & Referenties', 'Verwijzingen naar brondocumenten en literatuur.')
        ]
        content_sections = content.get('sections', [])
        for heading, default_body in sections:
            p = doc.add_paragraph()
            run = p.add_run(heading)
            run.bold = True; run.font.size = Pt(14); run.font.color.rgb = RGBColor(0, 51, 102)
            body = default_body
            for s in content_sections:
                if heading.lower()[:5] in s.get('heading', '').lower():
                    body = s.get('body', default_body); break
            doc.add_paragraph(body)
        doc.save(filepath)

    # ── eLearning Generator ──────────────────────────────────────────────
    def _generate_elearning(self, filepath, title, agent_name, content):
        import html as htmlmod
        content_sections = content.get('sections', [])
        pages = [{'title': s.get('heading', 'Sectie'), 'content': s.get('body', '')} for s in content_sections]
        if not pages:
            pages = [{'title': title, 'content': 'Geen inhoud beschikbaar.'}]
        pages_html = ''
        for i, page in enumerate(pages):
            body = htmlmod.escape(page.get('content', ''), quote=False)
            pages_html += f'<div class="page" id="page-{i}" style="display:{"block" if i==0 else "none"};"><h2>{htmlmod.escape(page.get("title",""))}</h2><div class="content">{body}</div></div>'
        nav_items = ''.join(
            f'<a href="#" class="nav-item" onclick="showPage({i});return false;" id="nav-{i}">{htmlmod.escape(p.get("title","Sectie "+str(i+1)))[:40]}</a>'
            for i, p in enumerate(pages)
        )
        html_content = f'''<!DOCTYPE html>
<html lang="nl"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>eLearning: {htmlmod.escape(title)}</title>
<style>
*{{margin:0;padding:0;box-sizing:border-box;}}
body{{font-family:Calibri,Arial,sans-serif;display:flex;min-height:100vh;background:#f0f2f5;}}
.sidebar{{width:260px;background:#003366;color:#fff;padding:20px 0;position:fixed;height:100vh;overflow-y:auto;}}
.sidebar h3{{padding:15px 20px;font-size:14px;text-transform:uppercase;color:#F5C518;}}
.nav-item{{display:block;padding:10px 20px;color:#ccc;text-decoration:none;font-size:13px;border-left:3px solid transparent;}}
.nav-item:hover,.nav-item.active{{color:#fff;background:rgba(255,255,255,0.1);border-left-color:#F5C518;}}
.main{{margin-left:260px;flex:1;padding:0;}}
.progress-bar{{height:4px;background:#003366;width:100%;}}
.progress-fill{{height:100%;background:#F5C518;transition:width 0.3s;width:0%;}}
.content-area{{padding:40px;max-width:900px;}}
.content-area h2{{color:#003366;margin-bottom:20px;font-size:24px;}}
.content-area .content{{line-height:1.7;color:#333;}}
.footer{{padding:20px 40px;border-top:1px solid #ddd;display:flex;justify-content:space-between;}}
.btn{{padding:10px 24px;border:none;border-radius:4px;cursor:pointer;font-size:14px;}}
.btn-prev{{background:#e0e0e0;color:#333;}}
.btn-next{{background:#003366;color:#fff;}}
.brand{{font-size:11px;color:#999;margin-top:20px;padding:15px 20px;border-top:1px solid rgba(255,255,255,0.1);}}
@media(max-width:768px){{.sidebar{{width:100%;height:auto;position:relative;}}body{{flex-direction:column;}}.main{{margin-left:0;}}}}
</style></head><body>
<div class="sidebar"><h3>JvG Consultancy</h3>{nav_items}
<div class="brand">eLearning Module<br>Gegenereerd: {datetime.now().strftime("%d-%m-%Y")}</div></div>
<div class="main"><div class="progress-bar"><div class="progress-fill" id="progress"></div></div>
<div class="content-area">{pages_html}</div>
<div class="footer"><button class="btn btn-prev" onclick="prevPage()">\u2190 Vorige</button><span id="counter">1 / {len(pages)}</span><button class="btn btn-next" onclick="nextPage()">Volgende \u2192</button></div></div>
<script>let current=0,total={len(pages)};
function showPage(n){{document.querySelectorAll('.page').forEach((p,i)=>p.style.display=i===n?'block':'none');
document.querySelectorAll('.nav-item').forEach((a,i)=>a.classList.toggle('active',i===n));
current=n;document.getElementById('counter').textContent=(n+1)+' / '+total;
document.getElementById('progress').style.width=((n+1)/total*100)+'%';}}
function nextPage(){{if(current<total-1)showPage(current+1);}}
function prevPage(){{if(current>0)showPage(current-1);}}
showPage(0);</script></body></html>'''
        with open(filepath, 'w', encoding='utf-8') as f:
            f.write(html_content)

    # ── XLSX Generator ─────────────────────────────────────────────────────
    def _generate_xlsx(self, filepath, title, xlsx_tables):
        from openpyxl import Workbook
        from openpyxl.styles import Font, PatternFill, Alignment, Border, Side

        wb = Workbook()
        first = True
        header_fill = PatternFill(start_color='003366', end_color='003366', fill_type='solid')
        header_font = Font(name='Calibri', size=10, bold=True, color='FFFFFF')
        cell_font = Font(name='Calibri', size=10)
        thin_border = Border(
            left=Side(style='thin', color='D1D5DB'), right=Side(style='thin', color='D1D5DB'),
            top=Side(style='thin', color='D1D5DB'), bottom=Side(style='thin', color='D1D5DB'))
        alt_fill = PatternFill(start_color='F2F2F2', end_color='F2F2F2', fill_type='solid')

        for tbl_name, tbl_data in xlsx_tables.items():
            headers = tbl_data.get('headers', [])
            rows = tbl_data.get('rows', [])
            if not headers:
                continue
            ws = wb.active if first else wb.create_sheet(title=tbl_name[:31])
            first = False
            ws.title = tbl_name[:31]
            ws.merge_cells(start_row=1, start_column=1, end_row=1, end_column=len(headers))
            title_cell = ws.cell(row=1, column=1, value=f'{title} — {tbl_name.replace("_", " ").title()}')
            title_cell.font = Font(name='Calibri', size=12, bold=True, color='003366')
            ws.cell(row=2, column=1, value=f'Versie 1.0 | {datetime.now().strftime("%d-%m-%Y")}').font = Font(size=9, color='868E96')
            header_row = 4
            for c_idx, h in enumerate(headers, 1):
                cell = ws.cell(row=header_row, column=c_idx, value=h)
                cell.fill = header_fill; cell.font = header_font
                cell.alignment = Alignment(horizontal='center', vertical='center'); cell.border = thin_border
            for r_idx, row in enumerate(rows, header_row + 1):
                for c_idx, val in enumerate(row, 1):
                    cell = ws.cell(row=r_idx, column=c_idx, value=val)
                    cell.font = cell_font; cell.border = thin_border
                    cell.alignment = Alignment(vertical='center', wrap_text=True)
                    if (r_idx - header_row) % 2 == 0:
                        cell.fill = alt_fill
        wb.save(filepath)

    # ── Timed call wrapper ─────────────────────────────────────────────────
    def _timed_call(self, func, *args, timeout=180, **kwargs):
        result = [None]
        error = [None]
        def target():
            try:
                result[0] = func(*args, **kwargs)
            except Exception as e:
                error[0] = e
        t = threading.Thread(target=target, daemon=True)
        t.start()
        t.join(timeout=timeout)
        if t.is_alive():
            log.error(f'Timed call timeout: {func.__name__} exceeded {timeout}s')
            return None
        if error[0]:
            raise error[0]
        return result[0]

    # ── Parse JSON response ────────────────────────────────────────────────
    def _parse_json_response(self, raw):
        if not raw or len(raw) < 20:
            return None
        try:
            return json.loads(raw)
        except json.JSONDecodeError:
            pass
        patterns = [
            r'```json\s*\n(.*?)\n\s*```',
            r'```\s*\n(.*?)\n\s*```',
            r'\{[\s\S]*\}',
        ]
        for pat in patterns:
            m = re.search(pat, raw, re.DOTALL)
            if m:
                try:
                    return json.loads(m.group(1) if m.lastindex else m.group(0))
                except (json.JSONDecodeError, AttributeError):
                    continue
        return None

    # ── MD to HTML converter ───────────────────────────────────────────────
    def _md2html(self, md):
        html = md
        html = re.sub(r'^> \s*⚠️\s*(.+)$', r'<div class="callout callout-warning">⚠️ \1</div>', html, flags=re.MULTILINE)
        html = re.sub(r'^> \s*ℹ️\s*(.+)$', r'<div class="callout callout-info">ℹ️ \1</div>', html, flags=re.MULTILINE)
        html = re.sub(r'^> \s*✅\s*(.+)$', r'<div class="callout callout-success">✅ \1</div>', html, flags=re.MULTILINE)
        html = re.sub(r'^> \s*🔴\s*(.+)$', r'<div class="callout callout-critical">🔴 \1</div>', html, flags=re.MULTILINE)
        html = re.sub(r'^> (.+)$', r'<blockquote>\1</blockquote>', html, flags=re.MULTILINE)
        html = re.sub(r'^### (.+)$', r'<h3>\1</h3>', html, flags=re.MULTILINE)
        html = re.sub(r'^## (.+)$', r'<h2>\1</h2>', html, flags=re.MULTILINE)
        html = re.sub(r'^# (.+)$', r'<h2>\1</h2>', html, flags=re.MULTILINE)
        html = re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', html)
        html = re.sub(r'\*(.+?)\*', r'<em>\1</em>', html)
        # Tables
        lines = html.split('\n')
        in_table = False; tbl = []; out = []
        for line in lines:
            if '|' in line and not line.strip().startswith('<'):
                cells = [c.strip() for c in line.split('|')[1:-1]]
                if all(set(c) <= {'-', ' ', ':'} for c in cells):
                    continue
                if not in_table:
                    tbl = ['<table>', '<thead><tr>' + ''.join(f'<th>{c}</th>' for c in cells) + '</tr></thead><tbody>']
                    in_table = True
                else:
                    tbl.append('<tr>' + ''.join(f'<td>{c}</td>' for c in cells) + '</tr>')
            else:
                if in_table:
                    tbl.append('</tbody></table>'); out.append('\n'.join(tbl)); tbl = []; in_table = False
                out.append(line)
        if in_table:
            tbl.append('</tbody></table>'); out.append('\n'.join(tbl))
        html = '\n'.join(out)
        html = re.sub(r'^- (.+)$', r'<li>\1</li>', html, flags=re.MULTILINE)
        html = re.sub(r'^(\d+)\. (.+)$', r'<li>\2</li>', html, flags=re.MULTILINE)
        html = re.sub(r'((?:<li>.*</li>\n?)+)', r'<ul>\1</ul>', html)
        return html

    # ── Build System Prompt (Fase 1 — Content Generation) ────────────────
    def _build_prompt(self, agent_id, workflow_id, task_desc, context, metadata_json):
        personality = AGENT_PERSONALITIES.get(agent_id, DEFAULT_PERSONALITY)

        sop_context = ''
        style_context = ''
        try:
            sop_path = '/root/projects/jg/MASTER_SOP.md'
            if os.path.exists(sop_path):
                with open(sop_path, 'r', encoding='utf-8') as f:
                    c = f.read()
                # §3.5 Deliverables Matrix
                start = c.find('### 3.5 Standaard Deliverables Matrix')
                sop_context = c[start:start+2000] if start >= 0 else c[:2000]
                # §14 SST Protocol
                sst_start = c.find('### 14.2 Launchpad API Sync')
                if sst_start >= 0:
                    sop_context += '\n\n' + c[sst_start:sst_start+1000]
        except Exception:
            pass

        # MASTER_STYLEGUIDE context
        try:
            style_path = '/root/projects/jg/MASTER_STYLEGUIDE.md'
            if os.path.exists(style_path):
                with open(style_path, 'r', encoding='utf-8') as f:
                    sc = f.read()
                # §1 Kleurenpalet + §2 Format + §4 Vrijheidsmarge
                style_context = sc[:3000]
        except Exception:
            pass

        workflow_instruction = WORKFLOW_PROMPTS.get(workflow_id, task_desc)

        system = f"""{personality}

## CONTENT GENERATION — FASE 1
Je genereert de INHOUD voor een professioneel HSEQ document. De backend handelt de opmaak af (DOCX/PPTX/HTML).
Je hoeft GEEN opmaak te maken — alleen gestructureerde inhoud.

## JSON-OUTPUT STRUCTUUR (COMPACT)
Retourneer ALLEEN een geldig JSON-object:
{{
  "title": "Documenttitel",
  "docx_content": {{
    "sections": [
      {{"heading": "1. Inleiding & Scope", "body": "Uitgebreide tekst met markdown (**vet**, - bullets, | tabellen |)"}},
      {{"heading": "2. Analyse", "body": "..."}},
      {{"heading": "3. Bevindingen", "body": "..."}},
      {{"heading": "4. Risicobeoordeling", "body": "..."}},
      {{"heading": "5. Aanbevelingen", "body": "..."}},
      {{"heading": "6. Actielijst", "body": "..."}}
    ]
  }},
  "xlsx_tables": {{
    "actielijst": {{
      "headers": ["#", "Actie", "Verantwoordelijke", "Deadline", "Status", "Prioriteit"],
      "rows": [["1", "...", "...", "...", "Open", "Hoog"]]
    }}
  }},
  "quiz_questions": [
    {{"question": "...", "options": ["A) ...", "B) ...", "C) ...", "D) ..."], "correct": 0, "explanation": "..."}}
  ]
}}

### KWALITEITSEISEN (NON-NEGOTIABLE):
1. **Diepgang**: Elke sectie bevat SPECIFIEKE, BRUIKBARE content — geen vulling, geen algemeenheden
2. **Wetgeving**: Verwijs expliciet naar relevante artikelen (Arbowet art. 3.2, BRZO art. 4.1, etc.)
3. **Voorbeelden**: Minimaal 2 concrete voorbeelden per sectie uit (petro)chemie/offshore/oil & gas
4. **Tabellen**: MINIMAAL 8 rijen met REALISTISCHE data — stofnamen, concentraties, data, functies
5. **Actiegericht**: Elke aanbeveling is SMART (Specifiek, Meetbaar, Acceptabel, Realistisch, Tijdgebonden)
6. **Geen AI-clichés**: Verboden: "Het is belangrijk...", "Samenvattend...", "In deze sectie zullen we..."
7. **Nederlands**: Alle tekst in zakelijk Nederlands
8. **Volume**: Minimaal 400 woorden per sectie body

## MASTER_SOP §3.5 + §14 (SST Protocol)
{sop_context}

## MASTER_STYLEGUIDE (Visuele Regels — Kleurenpalet, Typografie, Branding)
{style_context}

## TAAK: {workflow_instruction}"""

        user_parts = []
        if context:
            user_parts.append(f"## Input Data\n{context}")
        else:
            user_parts.append(f"## Taak\n{task_desc}")

        if metadata_json:
            try:
                meta = json.loads(metadata_json)
                kb = meta.get('kb_context', '')
                if kb:
                    user_parts.append(f"\n## Kennisbank Context (HSEQ Procedures & Wetgeving)\n{kb[:6000]}")
            except (json.JSONDecodeError, TypeError):
                pass

        return {'system': system, 'user': '\n'.join(user_parts)}

    # ── AI Call ────────────────────────────────────────────────────────────
    def _ai_call(self, messages, max_tokens=8000, max_retries=3):
        for attempt in range(max_retries):
            try:
                payload = json.dumps({
                    "model": AI_MODEL,
                    "messages": messages,
                    "stream": False,
                    "temperature": 0.7,
                    "max_tokens": max_tokens
                }).encode('utf-8')
                req = urllib.request.Request(AI_API_URL, data=payload, headers={
                    "Content-Type": "application/json",
                    "Authorization": f"Bearer {AI_API_KEY}"
                })
                ctx = ssl.create_default_context()
                ctx.check_hostname = False
                ctx.verify_mode = ssl.CERT_NONE
                with urllib.request.urlopen(req, timeout=API_TIMEOUT, context=ctx) as resp:
                    data = json.loads(resp.read().decode('utf-8'))
                    return data.get('choices', [{}])[0].get('message', {}).get('content', '')
            except Exception as e:
                log.warning(f'AI call attempt {attempt+1} failed: {e}')
                if attempt == max_retries - 1:
                    raise
                time.sleep(2 ** attempt)
        return ""

    # ── DB Operations ──────────────────────────────────────────────────────
    def _update_task(self, task_id, status, result, deliverable_file=None):
        conn = sqlite3.connect(DB_PATH)
        conn.execute('BEGIN IMMEDIATE')
        try:
            conn.execute(
                "UPDATE agent_tasks SET status=?, result=?, deliverable_file=?, completed_at=datetime('now') WHERE id=?",
                (status, result, deliverable_file, task_id))
            conn.commit()
        except Exception:
            conn.rollback()
            raise
        finally:
            conn.close()

    def _register_deliverable(self, task_id, agent_id, agent_name, task_desc, filepath, file_type):
        try:
            conn = sqlite3.connect(DB_PATH)
            try:
                conn.execute(
                    "INSERT INTO deliverables (title, agent_name, file_path, file_type, status, version, created_at) VALUES (?,?,?,?,?,?,datetime('now'))",
                    (task_desc, agent_name, filepath, file_type, 'approved', '1.0'))
                conn.commit()
            except sqlite3.OperationalError:
                log.warning('Deliverables table not found, skipping registration')
            conn.close()
        except Exception as e:
            log.warning(f'_register_deliverable error: {e}')

    def _update_changelog(self, task_id, agent_name, task_desc, deliverables):
        ts = datetime.now().strftime('%Y-%m-%d')
        changelog_path = os.path.join(LOG_DIR, 'changelog.md')
        header = '| Versie | Datum | Wijziging | Agent |\n|--------|-------|-----------|-------|\n'
        row = f'| v1.0 | {ts} | {task_desc[:60]} | {agent_name} |\n'
        if not os.path.exists(changelog_path):
            with open(changelog_path, 'w', encoding='utf-8') as f:
                f.write(f'# Changelog — {os.path.basename(PROJECT_DIR)}\n\n{header}{row}')
        else:
            with open(changelog_path, 'a', encoding='utf-8') as f:
                f.write(row)


# ── Singleton ────────────────────────────────────────────────────────────────
executor = TaskExecutor()


def register_executor_routes(flask_app, BASE_PATH="/hseq-dashboard"):
    from flask import jsonify

    @flask_app.route(BASE_PATH + '/api/agents/executor/status')
    def executor_status():
        return jsonify({
            'running': executor.running,
            'current_task': executor.current_task_id,
            'pending_tasks': executor.get_pending_count()
        })

    @flask_app.route(BASE_PATH + '/api/agents/executor/restart', methods=['POST'])
    def executor_restart():
        executor.stop()
        executor.start()
        return jsonify({'ok': True, 'message': 'Executor herstart (v3.0 Phased Pipeline)'})

    @flask_app.route(BASE_PATH + '/api/agents/task/<int:task_id>/retry', methods=['POST'])
    def task_retry(task_id):
        conn = sqlite3.connect(DB_PATH)
        conn.execute("UPDATE agent_tasks SET status='pending', result=NULL, started_at=NULL, completed_at=NULL WHERE id=?", (task_id,))
        conn.commit()
        conn.close()
        return jsonify({'ok': True, 'message': f'Taak {task_id} opnieuw in queue'})

    @flask_app.route(BASE_PATH + '/api/agents/task/<int:task_id>/cancel', methods=['POST'])
    def task_cancel(task_id):
        conn = sqlite3.connect(DB_PATH)
        row = conn.execute("SELECT status FROM agent_tasks WHERE id=?", (task_id,)).fetchone()
        if not row:
            conn.close()
            return jsonify({'error': 'Taak niet gevonden'}), 404
        if row[0] in ('completed', 'cancelled'):
            conn.close()
            return jsonify({'error': f'Taak is al {row[0]}'}), 400
        conn.execute("UPDATE agent_tasks SET status='cancelled', completed_at=datetime('now'), result='Geannuleerd door gebruiker' WHERE id=?", (task_id,))
        conn.commit()
        conn.close()
        executor._sync_intelligence_action(task_id, 'failed')
        return jsonify({'ok': True, 'message': f'Taak {task_id} geannuleerd'})
