# ============================================================
# MODULE: Agent Task Executor — Background thread for AI task execution
# HSEQ Intelligence Dashboard
# ARCHITECTURE UPGRADE v2.0 — Multi-Deliverable Suite Generation
# ============================================================

import json
import sqlite3
import threading
import time
import traceback
import urllib.request
import os
import logging
import re
import ssl
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')

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

# Module-level defaults (overridden per-task in _resolve_project_dir)
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):
    """Determine project directory from task context. MASTER_SOP compliant."""
    global PROJECT_DIR, DELIV_HTML, DELIV_DOCX, DELIV_XLSX, DELIV_PPTX, DELIV_SCORM, LOG_DIR

    project_dir = None

    # 1. Check for OPSLAG: directive in context
    if context and 'OPSLAG:' in context:
        m = re.search(r'OPSLAG:\s*(\S+)', context)
        if m:
            project_dir = m.group(1).rstrip('/')

    # 2. Auto-generate project name from task description
    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}')

    # Ensure full MASTER_SOP directory structure
    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 (MASTER_SOP §3.5) ───────────────────────────────────
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 personality prompts ───────────────────────────────────────────────
AGENT_PERSONALITIES = {
    # 01_Engineering_Product
    "software_architect": "Je bent een Software Architect met expertise in Python, Flask, database design en API ontwikkeling. Je ontwerpt robuuste, schaalbare systemen.",
    "backend_developer": "Je bent een Backend Architect gespecialiseerd in API's, microservices en database-architectuur. Je code is production-ready.",
    "frontend_developer": "Je bent een Frontend Developer met expertise in React, Vue.js en responsive design. Je bouwt gebruiksvriendelijke interfaces.",
    "database_administrator": "Je bent een Database Administrator met diepe kennis van PostgreSQL, SQLite en query-optimalisatie. Je garandeert data-integriteit.",
    "product_owner": "Je bent een Product Owner die roadmap, user stories en prioritisering beheert. Je stemt af met stakeholders.",
    "sprint_prioritizer": "Je bent een Sprint Prioritizer die backlog grooming, velocity tracking en capacity planning optimaliseert.",
    # 02_Design_Creative
    "brand_strategist": "Je bent een Brand Guardian die merkidentiteit, positioning en huisstijlrichtlijnen bewaakt en versterkt.",
    "graphic_designer": "Je bent een Graphic Designer die infographics, iconen en visuele elementen ontwerpt met oog voor detail.",
    "motion_designer": "Je bent een Motion Designer die animaties en visuele storytelling produceert voor professionele presentaties.",
    "presentation_specialist": "Je bent een Presentation Specialist die professionele presentaties en rapportages ontwerpt met visuele elementen.",
    "ui_designer": "Je bent een UI Designer die wireframes, mockups en design systems ontwerpt met focus op gebruiksvriendelijkheid.",
    "ux_architect": "Je bent een UX Architect die user experience optimaliseert door middel van onderzoek, interactieontwerp en usability testing.",
    # 03_Orchestration
    "agency_partner": "Je bent een Agency Partner die agent-pipelines beheert, taakverdeling coördineert en kwaliteitscontrole uitvoert.",
    "business_analyst": "Je bent een Business Analyst die pipeline analytics, KPI's en data-gedreven rapportages produceert.",
    "project_manager": "Je bent een Project Manager die planningen, resource allocatie en risicomanagement overzichten produceert.",
    "resource_planner": "Je bent een Experiment Tracker die A/B tests ontwerpt, resource allocatie optimaliseert en metrics analyseert.",
    "communication_manager": "Je bent een Communication Manager die Git workflow governance, documentbeheer en communicatielijnen beheert.",
    "scrum_master": "Je bent een Scrum Master die sprints begeleidt, retrospectives faciliteert en impediments oplost.",
    # 04_GameDev_Unity_Roblox
    "character_artist": "Je bent een Character Artist die 3D modellen, textures en rigging creëert voor games.",
    "game_designer": "Je bent een Game Designer die game mechanics, systems en balancering ontwerpt.",
    "level_designer": "Je bent een Level Designer die omgevingen, gameplay flow en iteratie ontwerpt.",
    "roblox_scripter": "Je bent een Roblox Systems Scripter die Luau code schrijft voor Roblox systemen en monetization.",
    "sound_engineer": "Je bent een Game Audio Engineer die geluidseffecten, muziek en interactief geluid produceert.",
    "unity_expert": "Je bent een Unity Architect die C#, ECS en performantie optimalisatie beheerst.",
    # 05_HSEQ_Support
    "compliance_auditor": "Je bent een Compliance Auditor gespecialiseerd in wet- en regelgeving audits. Je werkt systematisch en produceert gestructureerde bevindingen met prioritering.",
    "environmental_compliance": "Je bent een Environmental Compliance Manager met expertise in emissies, vergunningen en de Omgevingswet.",
    "hseq_specialist": "Je bent een HSEQ Specialist met diepe expertise in BRZO, Arbowet, Milieuregelgeving en kwaliteitsmanagement. Je antwoorden zijn feitelijk, beknopt en direct toepasbaar.",
    "plaud_specialist": "Je bent een Plaud Specialist die audio transcripties analyseert, samenvattingen maakt en actiepunten extraheert.",
    "qa_engineer": "Je bent een QA Engineer met diepe kennis van ISO 9001, KAM-systemen en procesvalidatie. Je output is gestructureerd en controleerbaar.",
    "support_lead": "Je bent een Support Lead die incidentafhandeling, kennisbankbeheer en escalatie coördineert.",
    "technical_writer": "Je bent een Technical Writer die heldere, gestructureerde documentatie produceert. Procedures, handleidingen en rapporten zijn jouw specialiteit.",
    "risk_management": "Je bent een Risk Management Specialist met expertise in HAZOP, LOPA, QRA en Bow-Tie analyse. Je denkt in risicomatrices en barrières.",
    "training_generator": "Je bent een Training Generator die effectieve trainingen ontwerpt: toolbox talks, e-learning modules en VCA-voorerichte materialen.",
}

DEFAULT_PERSONALITY = "Je bent een HSEQ AI-assistent. Je antwoorden zijn feitelijk, beknopt en hyper-professioneel."

# ── Workflow prompt templates ────────────────────────────────────────────────
WORKFLOW_PROMPTS = {
    "rie_opstellen": """Stel een volledige Risico-Inventarisatie & Evaluatie (RI&E) op.

OUTPUT FORMAAT: Je MOET een JSON-object retourneren met deze structuur:
{
  "title": "...",
  "docx_content": {
    "sections": [
      {"heading": "1. Bedrijfsgegevens", "body": "..."},
      {"heading": "2. Inventarisatie Gevaren", "body": "..."},
      {"heading": "3. Risicobeoordeling", "body": "..."},
      {"heading": "4. Maatregelen", "body": "..."},
      {"heading": "5. Evaluatie", "body": "..."}
    ]
  },
  "xlsx_tables": {
    "risicomatrix": {
      "headers": ["Gevaar", "Bron", "Mogelijk gevolg", "W", "A", "Risicoklasse", "Bestaande maatregel", "Aanbeveling"],
      "rows": [["...", "...", "...", 3, 2, "M", "...", "..."]]
    },
    "actielijst": {
      "headers": ["#", "Actie", "Verantwoordelijke", "Deadline", "Status", "Prioriteit"],
      "rows": [["1", "...", "...", "...", "Open", "Hoog"]]
    }
  },
  "tables_for_html": [
    {"title": "Risicomatrix", "headers": ["Gevaar", "Bron", "Gevolg", "W", "A", "Klasse", "Maatregel", "Aanbeveling"], "rows": []}
  ]
}

Belangrijk: De JSON MOET geldig zijn. Alle tekst in Nederlands. Maak REALISTIEKE inhoud op basis van de input. Minimaal 8 risico's in de matrix, minimaal 6 acties in de actielijst.

Gebruik de volgende input:""",

    "tra_maken": """Stel een Taakrisicoanalyse (TRA) op.

OUTPUT FORMAAT: Je MOET een JSON-object retourneren:
{
  "title": "...",
  "docx_content": {
    "sections": [
      {"heading": "1. Taakbeschrijving", "body": "..."},
      {"heading": "2. Stappenplan", "body": "..."},
      {"heading": "3. Gefaren per Stap", "body": "..."},
      {"heading": "4. Risicobeoordeling", "body": "..."},
      {"heading": "5. Beheersmaatregelen", "body": "..."},
      {"heading": "6. Restrisico", "body": "..."}
    ]
  },
  "xlsx_tables": {
    "tra_checklist": {
      "headers": ["Stap", "Gevaar", "Risico voor", "W", "A", "Klasse", "Beheersmaatregel", "PBM", "Restrisico"],
      "rows": [["...", "...", "...", 3, 2, "M", "...", "...", "Laag"]]
    }
  },
  "tables_for_html": []
}

Gebruik de volgende input:""",

    "training_prep": """Bereid een training voor.

OUTPUT FORMAAT: Je MOET een JSON-object retourneren:
{
  "title": "...",
  "docx_content": {
    "sections": [
      {"heading": "1. Leerdoelen", "body": "..."},
      {"heading": "2. Module 1: ...", "body": "..."},
      {"heading": "3. Module 2: ...", "body": "..."},
      {"heading": "4. Praktijkvoorbeelden", "body": "..."},
      {"heading": "5. Evaluatie", "body": "..."}
    ]
  },
  "xlsx_tables": {},
  "tables_for_html": [],
  "quiz_questions": [
    {"question": "...", "options": ["A) ...", "B) ...", "C) ...", "D) ..."], "correct": 0}
  ]
}

Gebruik de volgende input:""",

    "compliance_check": """Voer een compliance check uit.

OUTPUT FORMAAT: Je MOET een JSON-object retourneren:
{
  "title": "...",
  "docx_content": {
    "sections": [
      {"heading": "1. Scope", "body": "..."},
      {"heading": "2. Toepasselijke Wetgeving", "body": "..."},
      {"heading": "3. Gap-analyse", "body": "..."},
      {"heading": "4. Prioritering", "body": "..."},
      {"heading": "5. Actieplan", "body": "..."},
      {"heading": "6. Bronvermelding", "body": "..."}
    ]
  },
  "xlsx_tables": {
    "gap_analyse": {
      "headers": ["#", "Wetgeving", "Eis", "Huidige situatie", "Gap", "Risico", "Actie", "Prioriteit"],
      "rows": [["1", "...", "...", "...", "...", "...", "...", "Hoog"]]
    },
    "actielijst": {
      "headers": ["#", "Actie", "Verantwoordelijke", "Deadline", "Status"],
      "rows": [["1", "...", "...", "...", "Open"]]
    }
  },
  "tables_for_html": []
}

Gebruik de volgende input:""",

    "audit_prep": """Bereid een audit voor.

OUTPUT FORMAAT: Je MOET een JSON-object retourneren:
{
  "title": "...",
  "docx_content": {
    "sections": [
      {"heading": "1. Auditdoel en Scope", "body": "..."},
      {"heading": "2. Auditcriteria", "body": "..."},
      {"heading": "3. Checklist", "body": "..."},
      {"heading": "4. Documentenlijst", "body": "..."},
      {"heading": "5. Tips voor Auditees", "body": "..."}
    ]
  },
  "xlsx_tables": {
    "audit_checklist": {
      "headers": ["#", "Controlepunt", "Referentie", "Status", "Bevinding", "Prioriteit"],
      "rows": [["1", "...", "...", "Open", "...", "Hoog"]]
    }
  },
  "tables_for_html": []
}

Gebruik de volgende input:""",

    "incident_analyse": """Voer een incidentanalyse uit.

OUTPUT FORMAAT: Je MOET een JSON-object retourneren:
{
  "title": "...",
  "docx_content": {
    "sections": [
      {"heading": "1. Feitenrelaas", "body": "..."},
      {"heading": "2. Oorzakenanalyse (5-Why)", "body": "..."},
      {"heading": "3. Contributerende Factoren", "body": "..."},
      {"heading": "4. Verbetermaatregelen", "body": "..."},
      {"heading": "5. Opvolging", "body": "..."}
    ]
  },
  "xlsx_tables": {
    "actielijst": {
      "headers": ["#", "Maatregel", "Type", "Verantwoordelijke", "Deadline", "Status", "Prioriteit"],
      "rows": [["1", "...", "Direct/Structuur", "...", "...", "Open", "Hoog"]]
    }
  },
  "tables_for_html": []
}

Gebruik de volgende input:""",
}

# Fallback prompt for direct agent tasks (no workflow)
DIRECT_TASK_PROMPT = """
OUTPUT FORMAAT: Je MOET een JSON-object retourneren met deze structuur:
{
  "title": "...",
  "docx_content": {
    "sections": [
      {"heading": "1. Inleiding & Scope", "body": "..."},
      {"heading": "2. Analyse & Bevindingen", "body": "..."},
      {"heading": "3. Risicobeoordeling", "body": "..."},
      {"heading": "4. Aanbevelingen", "body": "..."},
      {"heading": "5. Actielijst", "body": "..."},
      {"heading": "6. Bronvermelding", "body": "..."}
    ]
  },
  "xlsx_tables": {
    "actielijst": {
      "headers": ["#", "Actie", "Verantwoordelijke", "Deadline", "Status", "Prioriteit"],
      "rows": [["1", "...", "...", "...", "Open", "Hoog"]]
    }
  },
  "tables_for_html": []
}

Belangrijk: De JSON MOET geldig zijn. Gebruik Nederlandse tekst. Maak REALISTIEKE inhoud.
"""

API_TIMEOUT = 300  # seconds (increased for compact JSON output)
TASK_HARD_TIMEOUT = 600  # 10 min absolute max for full task execution
STALE_TASK_THRESHOLD = 900  # 15 min — mark as failed if running longer


class TaskExecutor:
    """Background thread executor for agent tasks — Multi-Deliverable Suite.

    Lifecycle states: pending → running → completed/failed
    Watchdog: auto-recovers stale running tasks on startup and periodically.
    Intelligence sync: updates linked intelligence_actions on completion.
    """

    def __init__(self):
        self._thread = None
        self._stop_event = threading.Event()
        self.running = False
        self.current_task_id = None
        self.current_task_started = None  # datetime when current task started
        self.poll_interval = 5
        self._watchdog_interval = 120  # check stale tasks every 2 min
        self._last_watchdog = 0

    def start(self):
        if self._thread and self._thread.is_alive():
            return
        # ── Recover stale tasks on startup ───────────────────────────────
        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 (lifecycle + watchdog + intelligence sync)')

    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):
        """Mark tasks as failed if they've been 'running' too long (e.g. after crash).
        Only targets tasks with a started_at timestamp older than STALE_TASK_THRESHOLD.
        Never recovers the task currently being processed by this executor.
        """
        conn = sqlite3.connect(DB_PATH)
        try:
            cutoff = (datetime.now() - timedelta(seconds=STALE_TASK_THRESHOLD)).isoformat()
            # Exclude the task currently being executed by this executor instance
            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]}. '
                     f'Executor herstart op {datetime.now().isoformat()}. Gebruik retry om opnieuw te starten.',
                     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:
                # Periodic watchdog for stale tasks
                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):
        """Update linked intelligence_actions status when a task completes/fails."""
        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':
                # Close all in_progress intelligence actions (best effort match)
                conn.execute(
                    "UPDATE intelligence_actions SET status='closed', closed_at=? WHERE status='in_progress'",
                    (datetime.now().isoformat(),)
                )
                conn.commit()
                log.info(f'Task {task_id}: Linked intelligence action(s) closed')
            elif task_status == 'failed':
                # Revert in_progress intelligence actions back to open so user can retry
                conn.execute(
                    "UPDATE intelligence_actions SET status='open', started_at=NULL WHERE status='in_progress'",
                )
                conn.commit()
                log.info(f'Task {task_id}: Linked intelligence action(s) reverted to open')
            conn.close()
        except Exception as e:
            log.warning(f'Task {task_id}: Intelligence sync failed: {e}')
            if conn:
                try:
                    conn.close()
                except:
                    pass

    # ── Execute task ───────────────────────────────────────────────────────
    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}: {agent_name} - {task_desc[:80]}')
        try:
            # Resolve project directory (MASTER_SOP compliant)
            _resolve_project_dir(task_desc, context)
            prompt = self._build_prompt(agent_id, workflow_id, task_desc, context, metadata_json)
            messages = [
                {"role": "system", "content": prompt['system']},
                {"role": "user", "content": prompt['user']}
            ]

            # ── Hard timeout wrapper ──────────────────────────────────────
            raw_result = self._timed_call(self._ai_call, messages, timeout=API_TIMEOUT)
            if raw_result is None:
                raise TimeoutError(f"AI call timeout na {API_TIMEOUT}s")

            # Parse JSON from AI response
            parsed = self._parse_json_response(raw_result, task_desc)
            if not parsed:
                log.warning(f'Task {task_id}: JSON parse failed — generating HTML fallback from raw response ({len(raw_result)} chars)')
                # Build minimal parsed structure from raw response
                parsed = {
                    'title': task_desc[:100],
                    'docx_content': {'sections': [
                        {'heading': task_desc[:100], 'body': raw_result[:8000]}
                    ]},
                    'xlsx_tables': {},
                    'quiz_questions': []
                }

            # Generate Multi-Deliverable Suite (with hard timeout)
            deliverables = self._timed_call(
                self._generate_suite, task_id, agent_id, agent_name, task_desc, workflow_id, parsed,
                timeout=TASK_HARD_TIMEOUT
            )
            if deliverables is None:
                raise TimeoutError(f"Deliverable generatie timeout na {TASK_HARD_TIMEOUT}s")

            # Build result summary
            summary = f"## Generated Deliverables\n"
            for d in deliverables:
                summary += f"- **{d['label']}** ({d['format']}): {d['path']}\n"
            summary += f"\n### Content Preview\n{raw_result[:1500]}"

            self._update_task(task_id, 'completed', summary, json.dumps(deliverables))

            # Register in deliverables DB
            for d in deliverables:
                try:
                    self._register_deliverable(task_id, agent_id, agent_name, task_desc, d['path'], d['format'])
                except Exception as reg_err:
                    log.warning(f'Task {task_id}: DB registration failed for {d["path"]}: {reg_err}')

            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 generated')

        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

    # ── Timed call wrapper ─────────────────────────────────────────────────
    def _timed_call(self, func, *args, timeout=180, **kwargs):
        """Run a function in a thread with a hard timeout. Returns None on timeout."""
        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, task_desc):
        """Extract JSON from AI response (handles markdown code blocks)."""
        if not raw or len(raw) < 20:
            log.warning(f'AI response too short ({len(raw) if raw else 0} chars)')
            return None
        
        # Log first 200 chars for debugging
        log.info(f'AI response start: {raw[:200].replace(chr(10), " ")}')
        
        # Try direct JSON parse
        try:
            result = json.loads(raw)
            log.info(f'JSON parsed directly, keys: {list(result.keys()) if isinstance(result, dict) else type(result)}')
            return result
        except json.JSONDecodeError as e:
            log.warning(f'Direct JSON parse failed: {e}')

        # Try extracting from markdown code block
        patterns = [
            r'```json\s*\n(.*?)\n\s*```',
            r'```\s*\n(.*?)\n\s*```',
            r'\{[\s\S]*\}',  # Last resort: find outermost JSON object
        ]
        for i, pat in enumerate(patterns):
            m = re.search(pat, raw, re.DOTALL)
            if m:
                try:
                    result = json.loads(m.group(1) if m.lastindex else m.group(0))
                    log.info(f'JSON parsed via pattern {i}, keys: {list(result.keys()) if isinstance(result, dict) else type(result)}')
                    return result
                except (json.JSONDecodeError, AttributeError) as e:
                    log.warning(f'Pattern {i} match failed: {e}')
                    continue
        log.error(f'All JSON parse attempts failed. Response length: {len(raw)}')
        return None

    # ── Multi-Deliverable Suite Generator ───────────────────────────────────
    def _generate_suite(self, task_id, agent_id, agent_name, task_desc, workflow_id, parsed):
        """Generate the full deliverable suite: DOCX + HTML + XLSX."""
        deliverables = []
        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]
        title = parsed.get('title', task_desc)

        # 1. DOCX — check if docx_content was provided by AI
        docx_content = parsed.get('docx_content', {})
        sections = docx_content.get('sections', [])
        if not sections:
            log.warning(f'Task {task_id}: NO docx_content in AI response — generating fallback DOCX from title')
            # Build minimal docx_content from available data
            parsed['docx_content'] = {
                'sections': [{'heading': title, 'body': task_desc + '\n\n*Automatisch gegenereerd — AI leverde geen gestructureerde content.*'}]
            }
            if 'xlsx_tables' in parsed and parsed['xlsx_tables']:
                for tbl_name, tbl_data in parsed['xlsx_tables'].items():
                    parsed['docx_content']['sections'].append({
                        'heading': f'Bijlage: {tbl_name.replace("_", " ").title()}',
                        'body': f'Tabel is beschikbaar in het XLSX-bestand.'
                    })
        try:
            docx_path = os.path.join(DELIV_DOCX, f'HSEQ_{safe_agent}_{safe_task}_{ts}_v1.0.docx')
            self._generate_docx(docx_path, title, agent_name, parsed)
            deliverables.append({'label': f'{title} (DOCX)', 'format': 'docx', 'path': docx_path})
            log.info(f'Task {task_id}: DOCX generated → {docx_path}')
        except Exception as e:
            log.warning(f'Task {task_id}: DOCX generation failed: {e}')

        # 2. XLSX (if tables present)
        xlsx_tables = parsed.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})
                log.info(f'Task {task_id}: XLSX generated → {xlsx_path}')
            except Exception as e:
                log.warning(f'Task {task_id}: XLSX generation failed: {e}')

        # 3. HTML (always)
        try:
            html_path = os.path.join(DELIV_HTML, f'HSEQ_{safe_agent}_{safe_task}_{ts}_v1.0.html')
            self._generate_html(html_path, title, agent_name, parsed)
            deliverables.append({'label': f'{title} (HTML)', 'format': 'html', 'path': html_path})
            log.info(f'Task {task_id}: HTML generated → {html_path}')
        except Exception as e:
            log.warning(f'Task {task_id}: HTML generation failed: {e}')

        # 4. PPTX (for training_generator / presentation_specialist)
        if agent_id in ('training_generator', 'presentation_specialist'):
            try:
                pptx_path = os.path.join(DELIV_PPTX, f'HSEQ_{safe_agent}_{safe_task}_{ts}_v1.0.pptx')
                self._generate_pptx(pptx_path, title, agent_name, parsed)
                deliverables.append({'label': f'{title} (PPTX)', 'format': 'pptx', 'path': pptx_path})
                log.info(f'Task {task_id}: PPTX generated → {pptx_path}')
            except Exception as e:
                log.warning(f'Task {task_id}: PPTX generation failed: {e}')

        # 5. Kennisdossier (DOCX)
        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, parsed)
            deliverables.append({'label': f'Kennisdossier: {title} (DOCX)', 'format': 'docx', 'path': kd_path})
            log.info(f'Task {task_id}: Kennisdossier generated → {kd_path}')
        except Exception as e:
            log.warning(f'Task {task_id}: Kennisdossier generation failed: {e}')

        # 6. eLearning Module (HTML)
        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, parsed)
            deliverables.append({'label': f'eLearning: {title} (HTML)', 'format': 'html', 'path': el_path})
            log.info(f'Task {task_id}: eLearning generated → {el_path}')
        except Exception as e:
            log.warning(f'Task {task_id}: eLearning generation failed: {e}')

        # 7. SCORM Package (ZIP) — SKIPPED per P1-7, not critical
        log.info(f'Task {task_id}: SCORM generation SKIPPED (not critical, will be added in future version)')

        # 8. Quiz (HTML + JSON)
        try:
            quiz_html_path = os.path.join(DELIV_HTML, f'quiz_{safe_agent}_{safe_task}_{ts}_v1.0.html')
            quiz_json_path = os.path.join(DELIV_HTML, f'quiz_{safe_agent}_{safe_task}_{ts}_v1.0.json')
            self._generate_quiz(quiz_html_path, quiz_json_path, title, agent_name, parsed)
            deliverables.append({'label': f'Quiz: {title} (HTML+JSON)', 'format': 'html', 'path': quiz_html_path})
            deliverables.append({'label': f'Quiz: {title} (JSON)', 'format': 'json', 'path': quiz_json_path})
            log.info(f'Task {task_id}: Quiz generated → {quiz_html_path}')
        except Exception as e:
            log.warning(f'Task {task_id}: Quiz generation failed: {e}')

        return deliverables

    # ── DOCX Generator ─────────────────────────────────────────────────────
    def _generate_docx(self, filepath, title, agent_name, parsed):
        try:
            from docx import Document
            from docx.shared import Pt, Cm, RGBColor
            from docx.enum.text import WD_ALIGN_PARAGRAPH
            from docx.enum.table import WD_TABLE_ALIGNMENT
            from docx.oxml.ns import nsdecls
            from docx.oxml import parse_xml
        except ImportError:
            log.error('python-docx not installed. Run: pip3 install python-docx')
            raise

        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
            hs.paragraph_format.space_before = Pt([18, 12, 8][level - 1])

        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)
        for _ in range(4):
            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)
        p = doc.add_paragraph()
        p.alignment = WD_ALIGN_PARAGRAPH.CENTER
        run = p.add_run('Classificatie: Intern — Directeur J. van Gemert')
        run.font.size = Pt(11)
        doc.add_page_break()

        # Sections
        docx_content = parsed.get('docx_content', {})
        sections = docx_content.get('sections', [])

        for sec in sections:
            heading = sec.get('heading', '')
            body = sec.get('body', '')

            if heading:
                doc.add_heading(heading, level=1)

            if body:
                # Split on double newline for paragraphs
                paragraphs = body.split('\n\n')
                for para_text in paragraphs:
                    para_text = para_text.strip()
                    if not para_text:
                        continue
                    # Check if it's a table
                    if '|' in para_text and para_text.count('|') >= 3:
                        self._add_table_from_md(doc, para_text)
                    elif para_text.startswith('- ') or para_text.startswith('* '):
                        # Bullet list
                        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')
                                for run in p.runs:
                                    run.font.size = Pt(11)
                    else:
                        p = doc.add_paragraph(para_text)
                        for run in p.runs:
                            run.font.size = Pt(11)

        # Tables from xlsx_tables as appendix
        xlsx_tables = parsed.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 _add_table_from_md(self, doc, md_text):
        lines = [l.strip() for l in md_text.split('\n') if '|' in l]
        if len(lines) < 2:
            return
        headers = [c.strip() for c in lines[0].split('|')[1:-1]]
        rows = []
        for line in lines[2:]:  # Skip separator line
            cells = [c.strip() for c in line.split('|')[1:-1]]
            if cells and not all(set(c) <= {'-', ' ', ':'} for c in cells):
                rows.append(cells)
        if headers:
            self._add_formatted_table(doc, headers, rows)

    def _add_formatted_table(self, doc, headers, rows):
        from docx.oxml.ns import nsdecls
        from docx.oxml import parse_xml
        table = doc.add_table(rows=1 + len(rows), cols=len(headers))
        table.style = 'Table Grid'
        table.alignment = 2  # CENTER
        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, parsed):
        try:
            from docx import Document
            from docx.shared import Pt, Cm, RGBColor
            doc = Document()
            style = doc.styles['Normal']
            style.font.name = 'Calibri'
            style.font.size = Pt(11)
            # Header
            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
            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.')
            ]
            # Use parsed content or kennisdossier_sections if available
            kd_sections = parsed.get('kennisdossier_sections', [])
            content_sections = parsed.get('docx_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)
                # Find matching content
                body = default_body
                for s in kd_sections:
                    if heading.lower() in s.get('heading', '').lower():
                        body = s.get('body', default_body); break
                if 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)
        except Exception as e:
            raise RuntimeError(f'Kennisdossier generation error: {e}')

    # ── eLearning Generator ────────────────────────────────────────────────
    def _generate_elearning(self, filepath, title, agent_name, parsed):
        try:
            content_sections = parsed.get('docx_content', {}).get('sections', [])
            elearning_pages = parsed.get('elearning_pages', [])
            pages = elearning_pages if elearning_pages else [
                {'title': s.get('heading', 'Sectie'), 'content': s.get('body', '')}
                for s in content_sections
            ]
            if not pages:
                pages = [{'title': title, 'content': parsed.get('summary', 'Geen inhoud beschikbaar.')}]
            import html as htmlmod
            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;letter-spacing:1px;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)
        except Exception as e:
            raise RuntimeError(f'eLearning generation error: {e}')

    # ── SCORM Generator ───────────────────────────────────────────────────
    def _generate_scorm(self, filepath, title, agent_name, parsed):
        try:
            import zipfile, html as htmlmod, io
            # Generate eLearning HTML
            el_buf = io.StringIO()
            self._generate_elearning_buf(el_buf, title, agent_name, parsed)
            el_html = el_buf.getvalue()
            # imsmanifest.xml
            safe_title = htmlmod.escape(title).replace('&', '&amp;')
            manifest = f'''<?xml version="1.0" encoding="UTF-8"?>
<manifest identifier="JvG_SCORM_{datetime.now().strftime('%Y%m%d%H%M%S')}" version="1.0"
  xmlns="http://www.imsproject.org/xsd/imscp_rootv1p1p2"
  xmlns:adlcp="http://www.adlnet.org/xsd/adlcp_rootv1p2"
  xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  xsi:schemaLocation="http://www.imsproject.org/xsd/imscp_rootv1p1p2 imscp_rootv1p1p2.xsd http://www.adlnet.org/xsd/adlcp_rootv1p2 adlcp_rootv1p2.xsd">
  <metadata><schema>ADL SCORM</schema><schemaversion>1.2</schemaversion></metadata>
  <organizations default="JvG_ORG">
    <organization identifier="JvG_ORG">
      <title>{safe_title}</title>
      <item identifier="item_1" identifierref="res_1">
        <title>{safe_title}</title>
      </item>
    </organization>
  </organizations>
  <resources>
    <resource identifier="res_1" type="webcontent" adlcp:scormtype="sco" href="index.html">
      <file href="index.html"/>
    </resource>
  </resources>
</manifest>'''
            with zipfile.ZipFile(filepath, 'w', zipfile.ZIP_DEFLATED) as zf:
                zf.writestr('imsmanifest.xml', manifest)
                zf.writestr('index.html', el_html)
        except Exception as e:
            raise RuntimeError(f'SCORM generation error: {e}')

    def _generate_elearning_buf(self, buf, title, agent_name, parsed):
        """Generate eLearning HTML to a buffer (reused by SCORM)."""
        import html as htmlmod
        content_sections = parsed.get('docx_content', {}).get('sections', [])
        elearning_pages = parsed.get('elearning_pages', [])
        pages = elearning_pages if elearning_pages else [
            {'title': s.get('heading', 'Sectie'), 'content': s.get('body', '')}
            for s in content_sections
        ]
        if not pages:
            pages = [{'title': title, 'content': parsed.get('summary', '')}]
        pages_html = ''
        for i, p in enumerate(pages):
            display = 'block' if i == 0 else 'none'
            pages_html += ('<div class="page" id="page-' + str(i) + '" style="display:' + display + ';"><h2>'
                           + htmlmod.escape(p.get('title', '')) + '</h2><div class="content">'
                           + htmlmod.escape(p.get('content', ''), quote=False) + '</div></div>')
        nav_items = ''
        for i, p in enumerate(pages):
            nav_items += ('<a href="#" class="nav-item" onclick="showPage(' + str(i)
                         + ');return false;">' + htmlmod.escape(p.get('title', 'S' + str(i+1)))[:40] + '</a>')
        buf.write('<!DOCTYPE html><html lang="nl"><head><meta charset="UTF-8"><title>'
                  + htmlmod.escape(title) + '</title><style>'
                  '*{margin:0;padding:0;box-sizing:border-box;}body{font-family:Calibri,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;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{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;}'
                  '.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;}.btn-next{background:#003366;color:#fff;}'
                  '</style></head><body>'
                  '<div class="sidebar"><h3>JvG Consultancy</h3>' + nav_items + '</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="if(current>0)showPage(current-1)">\u2190 Vorige</button>'
                  '<span id="counter">1/' + str(len(pages)) + '</span>'
                  '<button class="btn btn-next" onclick="if(current<total-1)showPage(current+1)">Volgende \u2192</button></div></div>'
                  '<script>let current=0,total=' + str(len(pages)) + ';'
                  'function showPage(n){document.querySelectorAll(".page").forEach(function(p,i){p.style.display=i===n?"block":"none";});current=n;'
                  'document.getElementById("counter").textContent=(n+1)+"/"+total;'
                  'document.getElementById("progress").style.width=((n+1)/total*100)+"%";}'
                  'showPage(0);</script></body></html>')

    # ── Quiz Generator ────────────────────────────────────────────────────
    def _generate_quiz(self, html_path, json_path, title, agent_name, parsed):
        import html as htmlmod, json as jsonmod
        # Get quiz questions from parsed or generate from sections
        quiz_questions = parsed.get('quiz_questions', [])
        if not quiz_questions:
            sections = parsed.get('docx_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 voor het juiste antwoord.'
            })
        quiz_data = {'title': f'Quiz: {title}', 'questions': quiz_questions[:10], 'passing_score': 70}
        with open(json_path, 'w', encoding='utf-8') as f:
            jsonmod.dump(quiz_data, f, ensure_ascii=False, indent=2)
        # Generate HTML
        questions_js = jsonmod.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;}}
.progress-info{{text-align:center;color:#666;font-size:14px;margin-bottom:20px;}}
.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(html_path, 'w', encoding='utf-8') as f:
            f.write(quiz_html)

    # ── XLSX Generator ─────────────────────────────────────────────────────
    def _generate_xlsx(self, filepath, title, xlsx_tables):
        try:
            from openpyxl import Workbook
            from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
        except ImportError:
            log.error('openpyxl not installed. Run: pip3 install openpyxl')
            raise

        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]

            # Title row
            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.row_dimensions[1].height = 30

            # Date
            ws.cell(row=2, column=1, value=f'Versie 1.0 | {datetime.now().strftime("%d-%m-%Y")}').font = Font(size=9, color='868E96')

            # Headers
            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

            # Data rows
            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

            # Auto-width (approximate)
            for c_idx in range(1, len(headers) + 1):
                max_len = max(len(str(headers[c_idx - 1])), 10)
                for row in rows:
                    if c_idx - 1 < len(row):
                        max_len = max(max_len, len(str(row[c_idx - 1])))
                ws.column_dimensions[chr(64 + c_idx) if c_idx <= 26 else 'A' + chr(64 + c_idx - 26)].width = min(max_len + 4, 40)

        wb.save(filepath)

    # ── HTML Generator (enhanced) ──────────────────────────────────────────
    def _generate_html(self, filepath, title, agent_name, parsed):
        import os
        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 Consultancy" style="height:40px;width:auto;margin-right:16px;">'
        except Exception:
            pass

        # Build content from parsed JSON
        content_html = ''
        docx_content = parsed.get('docx_content', {})
        for sec in docx_content.get('sections', []):
            heading = sec.get('heading', '')
            body = sec.get('body', '')
            if heading:
                content_html += f'<h2>{heading}</h2>\n'
            if body:
                content_html += self._md2html(body) + '\n'

        # Add tables from xlsx_tables
        xlsx_tables = parsed.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>{h}</th>'
                    content_html += '</tr></thead><tbody>'
                    for row in rows:
                        content_html += '<tr>'
                        for cell in row:
                            content_html += f'<td>{cell}</td>'
                        content_html += '</tr>'
                    content_html += '</tbody></table>\n'

        # Quiz (if present)
        quiz = parsed.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> {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}"> {opt}</label></div>\n'
                content_html += '<div class="quiz-reveal" onclick="this.textContent=this.textContent===\'Toon antwoord\'?\'Het correcte antwoord is: ' + chr(65 + q.get("correct", 0)) + '\'">Toon antwoord</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}"
            ".callout-critical{background:#FFF7ED;border-left:4px solid #FF6D00;color:#9A3412}"
            ".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}"
            ".quiz-reveal{padding:6px 12px;margin:4px 0 12px;color:#003366;cursor:pointer;font-size:12px;font-style:italic}"
            "@media print{body{font-size:11px;-webkit-print-color-adjust:exact;print-color-adjust:exact}.header,.footer,.callout,th{print-color-adjust:exact;-webkit-print-color-adjust:exact}}"
        )

        html = (
            f'<!DOCTYPE html><html lang="nl"><head><meta charset="UTF-8">'
            f'<title>{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">{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>{title}</td></tr>'
            f'<tr><td>Agent:</td><td>{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)

    # ── PPTX Generator ────────────────────────────────────────────────────
    def _generate_pptx(self, filepath, title, agent_name, parsed):
        try:
            from pptx import Presentation
            from pptx.util import Pt, Inches, Cm
            from pptx.dml.color import RGBColor
            from pptx.enum.text import PP_ALIGN, MSO_ANCHOR
        except ImportError:
            log.error('python-pptx not installed. Run: pip3 install python-pptx')
            raise

        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)
        light_gray = RGBColor(248, 249, 250)

        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
            return txBox

        def add_background(slide, color=brand_color):
            bg = slide.background
            fill = bg.fill
            fill.solid()
            fill.fore_color.rgb = color

        # Slide 1: Title
        slide = prs.slides.add_slide(prs.slide_layouts[6])  # blank
        add_background(slide, 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
        sections = parsed.get('docx_content', {}).get('sections', [])
        for sec in sections[:10]:  # max 10 slides
            heading = sec.get('heading', '')
            body = sec.get('body', '')
            if not heading:
                continue

            slide = prs.slides.add_slide(prs.slide_layouts[6])
            # Header bar
            shape = slide.shapes.add_shape(1, Inches(0), Inches(0), prs.slide_width, Inches(1.2))  # rectangle
            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
            p.alignment = PP_ALIGN.LEFT
            tf.margin_left = Inches(0.8)
            tf.vertical_anchor = MSO_ANCHOR.MIDDLE

            # Body content
            if body:
                # Clean markdown
                clean = body.replace('**', '').replace('##', '').replace('###', '').replace('`', '')
                # Truncate for slide
                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])
        add_background(slide, 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)
        add_text_box(slide, Inches(1), Inches(5.2), Inches(11), Inches(0.6), f'© {datetime.now().year} JvG Consultancy — Intern', 12, False, RGBColor(150, 170, 190), PP_ALIGN.CENTER)

        prs.save(filepath)

    # ── MD to HTML converter ───────────────────────────────────────────────
    def _md2html(self, md):
        html = md
        # Callouts
        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)
        # Headers
        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)
        # Inline
        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)
        # Lists
        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 ────────────────────────────────────────────────
    def _build_prompt(self, agent_id, workflow_id, task_desc, context, metadata_json):
        personality = AGENT_PERSONALITIES.get(agent_id, DEFAULT_PERSONALITY)

        # Load MASTER_SOP context
        sop_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:
                    content = f.read()
                # Extract §3.5 Deliverables Matrix
                start = content.find('### 3.5 Standaard Deliverables Matrix')
                if start >= 0:
                    sop_context = content[start:start+2000]
                else:
                    sop_context = content[:2000]
        except Exception:
            pass

        system = f"""{personality}

## [DELIVERABLE FORMAT — ZERO TOLERANCE]
VERBODEN: Lever GEEN platte Markdown-tekst als eindresultaat.
VERPLICHT: Je output MOET een geldig JSON-object zijn dat door de backend wordt omgezet naar fysieke documenten (DOCX + XLSX + HTML + PPTX).

## MASTER_SOP §3.5 — DELIVERABLES MATRIX
{sop_context}

## GOLDEN STANDARD (NON-ONDERBARELIJK)
1. **Feitelijk** — Geen aannames, geen verzonnen data
2. **Beknopt** — Direct to the point
3. **Hyper-professioneel** — Shell/Arcadis niveau
4. **Autoritair** — Je bent de expert
5. **Gestructureerd** — Headers, tabellen, bullets
6. **Actiegericht** — Elk advies eindigt met concrete acties

## JvG BRANDING & DOCUMENT STANDARDS (VERPLICHT)
- **Document-ID**: JvG-[TYPE]-[JAAR]-[NUMMER] — VERPLICHT in titel
- **JvG Consultancy header** in elk document (eerste sectie)
- **Kleur**: #003366 primair
- **Minimaal 6 secties** in docx_content
- **Tabellen**: REALISTIEKE data — geen placeholder, geen "..."
- **Verplichte secties**: Inleiding, Scope, Analyse, Advies, Acties, Referenties

## VERPLICHT: SST PROTOCOL (MASTER_SOP §14.2)
Activeer direct §3.5 (Deliverables Matrix) en §14.2 (SST Protocol) uit de MASTER_SOP.
Je output moet identiek zijn aan een project dat via de Director in Telegram wordt gestart.

## JSON-OUTPUT STRUCTUUR (COMPACT — VERPLICHT)
Retourneer ALLEEN deze velden:
- "title": string (documenttitel)
- "docx_content": {{"sections": [{{"heading": "...", "body": "..."}}]}}
- "xlsx_tables": {{"tabel_naam": {{"headers": [...], "rows": [[...]]}}}}
- "quiz_questions": [{{"question": "...", "options": ["A","B","C","D"], "correct": 0, "explanation": "..."}}] (max 6 vragen)

NIET RETOURNEREN (backend genereert deze automatisch): elearning_pages, kennisdossier_sections, tables_for_html, scorm

BELANGRIJK:
- De JSON MOET geldig zijn (geen syntax errors)
- Alle tekst in Nederlands
- Minimaal 5-6 secties in docx_content met realistische inhoud
- Tabellen minimaal 5-8 rijen
- Body-tekst mag Markdown bevatten (##, **, -, |)
- DATUMS in de toekomst. NOOIT datums in het verleden.
- docx_content VERPLICHT — minimaal 3 secties

## TAAK
Je ontvangt een taak van JvG Consultancy (HSEQ consultancy, olie/gas/petrochemie/offshore).
De deliverable moet direct bruikbaar zijn voor een professionele HSEQ consultant.
Taal: Nederlands."""

        # User message
        user_parts = []
        if workflow_id and workflow_id in WORKFLOW_PROMPTS:
            user_parts.append(WORKFLOW_PROMPTS[workflow_id])
        else:
            user_parts.append(DIRECT_TASK_PROMPT)

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

        # KB context
        if metadata_json:
            try:
                meta = json.loads(metadata_json)
                kb = meta.get('kb_context', '')
                if kb:
                    user_parts.append(f"\n## Relevante Kennisbank Extracten\n{kb[:3000]}")
            except (json.JSONDecodeError, TypeError):
                pass

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

    # ── AI Call ────────────────────────────────────────────────────────────
    def _ai_call(self, messages, 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": 8000
                }).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)
            safe_name = os.path.basename(filepath)
            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(f'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 (v2.0 Multi-Deliverable)'})

    @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):
        """Cancel a pending or running task."""
        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()
        # Revert linked intelligence action
        executor._sync_intelligence_action(task_id, 'failed')
        return jsonify({'ok': True, 'message': f'Taak {task_id} geannuleerd'})
