// ===================== QA App – komplette app.js =====================

// === DocBee: Token HIER eintragen ===
const DOCBEE_TOKEN = window.DOCBEE_TOKEN || ""; // DocBee Token eintragen"
const ENABLE_FALLBACK_NOTE = false; // auf true setzen, falls bei Fehler automatisch Notiz angelegt werden soll

window.addEventListener('error', e => {
    try {
        console.error(e.error || e.message || e);
    } catch (_) {}
    alert('JS-Fehler: ' + (e.message || e));
});

// === Keyboard Shortcuts ===
window.addEventListener('keydown', e => {
    // Alt + S to add a new step
    if (e.altKey && e.key.toLowerCase() === 's' && els.btnAddStep) {
        e.preventDefault();
        els.btnAddStep.click();
    }
});


// === DOM-Refs ===
const els = {
    yamlFile: document.getElementById('yamlFile'),
    stepsTableBody: document.querySelector('#stepsTable tbody'),
    tplName: document.getElementById('tplName'),
    statusTag: document.getElementById('statusTag'),
    module: document.getElementById('module'),
    moduleVersion: document.getElementById('moduleVersion'),
    pbxVersion: document.getElementById('pbxVersion'),
    tester: document.getElementById('tester'),
    olmNummer: document.getElementById('olmNummer'),
    docbeeUrl: document.getElementById('docbeeUrl'),
    btnAddStep: document.getElementById('btnAddStep'),
    btnSaveJSON: document.getElementById('btnSaveJSON'),
    loadJSON: document.getElementById('loadJSON'),
    btnAddGroup: document.getElementById('btnAddGroup'),
    btnExportTemplateYAML: document.getElementById('btnExportTemplateYAML'),
    btnExportAll: document.getElementById('btnExportAll'),
    gitlabTplSelect: document.getElementById('gitlabTplSelect'),
    gitlabTplStatus: document.getElementById('gitlabTplStatus'),
    docbeeTokenStatus: document.getElementById('docbeeTokenStatus'),
    btnGitlabReload: document.getElementById('btnGitlabReload'),
};
// === Drag Guard + Observer: Nur Drag über expliziten Griff zulassen ===
function enforceDragHandleOnly() {
    if (!els.stepsTableBody) return;
    els.stepsTableBody.querySelectorAll('tr[draggable="true"]').forEach(tr => tr.setAttribute('draggable', 'false'));
    els.stepsTableBody.querySelectorAll('.drag-handle').forEach(h => h.setAttribute('draggable', 'true'));
}
document.addEventListener('DOMContentLoaded', () => {
    if (els.stepsTableBody) {
        const mo = new MutationObserver(() => enforceDragHandleOnly());
        mo.observe(els.stepsTableBody, {
            childList: true,
            subtree: true
        });
        enforceDragHandleOnly();
    }
});
document.addEventListener('dragstart', (e) => {
    if (!e.target.closest('.drag-handle')) {
        e.preventDefault();
    }
}, {
    capture: true
});


let template = null; // {name,module,module_version,pbx_version,steps:[...]}


// === GitLab: Projektquelle für Templates ===
const GITLAB = {
    host: (window.GITLAB && window.GITLAB.host) || "https://git.steinert.cc",
    projectId: (window.GITLAB && window.GITLAB.projectId) || "qa/templates",
    ref: (window.GITLAB && window.GITLAB.ref) || "main",
    path: (window.GITLAB && window.GITLAB.path) || "templates",
    token: (window.GITLAB && window.GITLAB.token) || ""
};

function glHeaders() {
    const h = {
        "Accept": "application/json"
    };
    if (GITLAB.token && GITLAB.token.trim()) h["PRIVATE-TOKEN"] = GITLAB.token.trim();
    return h;
}
// Listet .yml/.yaml im Repo-Pfad
async function listGitlabTemplates() {
    const url = `${GITLAB.host}/api/v4/projects/${encodeURIComponent(GITLAB.projectId)}/repository/tree?path=${encodeURIComponent(GITLAB.path)}&ref=${encodeURIComponent(GITLAB.ref)}&per_page=100`;
    const r = await fetch(url, {
        headers: glHeaders()
    });
    if (!r.ok) throw new Error(`GitLab Tree ${r.status}`);
    const items = await r.json();
    return items.filter(it => it.type === "blob" && /\.ya?ml$/i.test(it.name)).map(it => ({
        name: it.name,
        path: it.path
    }));
}
// Holt RAW-Inhalt einer Datei
async function fetchGitlabFileRaw(filePath) {
    const url = `${GITLAB.host}/api/v4/projects/${encodeURIComponent(GITLAB.projectId)}/repository/files/${encodeURIComponent(filePath)}/raw?ref=${encodeURIComponent(GITLAB.ref)}`;
    const r = await fetch(url, {
        headers: glHeaders(),
        cache: "no-store"
    });
    if (!r.ok) throw new Error(`GitLab Raw ${r.status}`);
    return await r.text();
}
// Dropdown füllen
async function populateGitlabDropdown() {
    const sel = els.gitlabTplSelect,
        tag = els.gitlabTplStatus;
    if (!sel) return;
    sel.innerHTML = `<option value="">— GitLab-Templates laden —</option>`;
    try {
        tag && (tag.textContent = "GitLab: lade Liste…");
        const files = await listGitlabTemplates();
        files.forEach(f => {
            const opt = document.createElement('option');
            opt.value = f.path;
            opt.textContent = f.name;
            sel.appendChild(opt);
        });
        tag && (tag.textContent = files.length ? `GitLab: ${files.length} Vorlage(n)` : "GitLab: keine YAMLs gefunden");
    } catch (e) {
        tag && (tag.textContent = "GitLab: Fehler");
        console.error("[GitLab] list failed:", e);
        alert("GitLab-Liste konnte nicht geladen werden: " + e.message);
    }
}
// === Utils ===
const hasToken = () => typeof DOCBEE_TOKEN === 'string' && DOCBEE_TOKEN.trim().length > 0;
const escHTML = (s) => String(s ?? '').replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;');
const escAttr = (s) => String(s ?? '').replaceAll('"', '&quot;');
const safeName = (s) => String(s || '').replace(/\s+/g, '-').toLowerCase().replace(/[^\w.-]/g, '');
const linkify = (s) => /^https?:\/\//i.test(String(s || '')) ? `<a href="${escHTML(s)}" target="_blank" rel="noopener">${escHTML(s)}</a>` : escHTML(s);

// --- Utils: Logo laden + Text normalisieren ---
// ========= Text & Symbol Normalization (PDF-safe) =========
// Map many Unicode arrows/dashes to ASCII; avoids font issues in jsPDF
function replaceSymbols(s) {
    return String(s)
        // right arrows (→ ⇒ ⟶ ➔ ➜ …) → "->"
        .replace(/[\u2192\u21A6\u21E8\u27A1\u2794\u27F6\u27F7\u27F9\u279D\u279E\u279F\u27A0]/g, '->')
        // left arrows (← ⇐ ⟵ …) → "<-"
        .replace(/[\u2190\u21A4\u21E6\u2B05\u27F5]/g, '<-')
        // both directions (↔ ⇔ ⟷ …) → "<->"
        .replace(/[\u2194\u21D4\u27F7\u27F8\u2B04]/g, '<->')
        // normalize dashes
        .replace(/[–—‑]/g, '-')
        // ellipsis/multiply
        .replace(/…/g, '...')
        .replace(/×/g, 'x');
}

// Restrict to ASCII + Latin-1 (tabs/newlines allowed); replace others with '?'
function toPdfSafe(s) {
    return String(s).replace(/[^\x09\x0A\x0D\x20-\x7E\u00A0-\u00FF]/g, '?');
}

async function imgToDataURL(src) {
    return new Promise((resolve, reject) => {
        const img = new Image();
        img.crossOrigin = 'anonymous';
        img.onload = () => {
            const c = document.createElement('canvas');
            c.width = img.naturalWidth;
            c.height = img.naturalHeight;
            const ctx = c.getContext('2d');
            ctx.drawImage(img, 0, 0);
            resolve(c.toDataURL('image/png'));
        };
        img.onerror = reject;
        img.src = src;
    });
}

function sanitizeText(s) {
    // Inline text normalization for labels/titles/comments
    const out = String(s ?? '')
        .replace(/\u00A0/g, ' ') // NBSP -> space
        .replace(/[“”]/g, '"') // smart quotes -> ASCII
        .replace(/[’‘]/g, "'")
        .replace(/\s+/g, ' ')
        .trim();
    // Map arrows/dashes and limit to PDF-safe charset
    return toPdfSafe(replaceSymbols(out));
}

// ===== Funktion: download =====
// Hilfsfunktion: Client-seitiges Herunterladen als Datei.
function download(content, filename, type) {
    const blob = new Blob([content], {
        type
    });
    const url = URL.createObjectURL(blob);
    const a = document.createElement('a');
    a.href = url;
    a.download = filename;
    a.click();
    setTimeout(() => URL.revokeObjectURL(url), 1500);
}

// === Autosave der Meta-Felder ===
// ===== Funktion: updateAutosave =====
function updateAutosave() {
    const st = {
        module: els.module?.value || '',
        moduleVersion: els.moduleVersion?.value || '',
        pbxVersion: els.pbxVersion?.value || '',
        tester: els.tester?.value || '',
        docbeeUrl: els.docbeeUrl?.value || '',
    };
    try {
        localStorage.setItem('qaLiteState', JSON.stringify(st));
    } catch {}
}
window.addEventListener('load', () => {
    try {
        const st = JSON.parse(localStorage.getItem('qaLiteState') || '{}');
        if (st.module) els.module.value = st.module;
        if (st.moduleVersion) els.moduleVersion.value = st.moduleVersion;
        if (st.pbxVersion) els.pbxVersion.value = st.pbxVersion;
        if (st.tester) els.tester.value = st.tester;
        if (st.docbeeUrl) els.docbeeUrl.value = st.docbeeUrl;
    } catch {}
    // NEU: Token-Badge initial bewerten + GitLab-Dropdown füllen
    if (typeof updateTokenBadge === 'function') updateTokenBadge();
    if (els.gitlabTplSelect && typeof populateGitlabDropdown === 'function') {
        populateGitlabDropdown();
    }
});

['input', 'change'].forEach(ev => {
    [els.module, els.moduleVersion, els.pbxVersion, els.tester, els.docbeeUrl].forEach(el => el && el.addEventListener(ev, updateAutosave));
});

// === Vorlage laden (YAML/JSON) ===
// ===== Funktion: parseTemplate =====
function parseTemplate(text) {
    if (window.jsyaml && typeof jsyaml.load === 'function') {
        try {
            return jsyaml.load(text);
        } catch (e) {}
    }
    try {
        return JSON.parse(text);
    } catch (e) {}
    throw new Error('Vorlage konnte weder als YAML noch JSON gelesen werden.');
}
// ===== Funktion: loadYAMLText =====
// Zweck: siehe Inline-Kommentare im Funktionskörper.
function loadYAMLText(yamlText) {
    const obj = parseTemplate(yamlText);
    if (!obj || !Array.isArray(obj.steps)) throw new Error('Ungültige Vorlage: "steps" fehlt.');
    // Normalisieren: type→kind, default "step"
    obj.steps = obj.steps.map(s => {
        const k = s.kind || s.type || 'step';
        if (k === 'group') return {
            kind: 'group',
            title: s.title || s.name || '',
            collapsed: !!s.collapsed
        };
        return {
            kind: 'step',
            id: s.id,
            title: s.title,
            expected: s.expected,
            required: !!s.required,
            status: s.status || '',
            comment: s.comment || '',
            evidence: s.evidence || ''
        };
    });
    template = obj;
    if (els.tplName) els.tplName.textContent = obj.name || '—';
    if (obj.module) els.module.value = obj.module;
    if (obj.module_version) els.moduleVersion.value = obj.module_version;
    if (els.olmNummer) els.olmNummer.value = obj.olm_nummer || obj.olm || obj.olmNumber || '';
    if (obj.pbx_version) els.pbxVersion.value = obj.pbx_version;
    renderSteps(obj.steps);
    if (els.statusTag) els.statusTag.textContent = 'Vorlage geladen';
    updateAutosave();
}

// === DOM -> template ===
// ===== Funktion: captureEditsIntoTemplate =====
function captureEditsIntoTemplate() {
    if (!template) return;
    const rows = [...(els.stepsTableBody?.querySelectorAll('tr') || [])];
    template.steps = rows.map((row, i) => {
        const kind = row.getAttribute('data-kind') || 'step';
        if (kind === 'group') {
            return {
                kind: 'group',
                title: (row.querySelector('.tpl-group-title')?.value || '').trim(),
                collapsed: row.getAttribute('data-collapsed') === '1'
            };
        }
        return {
            kind: 'step',
            id: (row.querySelector('.tpl-id')?.value || '').trim() || `step-${String(i+1).padStart(3,'0')}`,
            title: (row.querySelector('.tpl-title')?.value || row.querySelector('.tpl-title')?.textContent || '').trim(),
            expected: (row.querySelector('.tpl-expected')?.value || '').trim(),
            required: !!(row.querySelector('.tpl-required')?.checked),
            status: row.querySelector('select.status')?.value || '',
            comment: row.querySelector('.run-comment')?.value || '',
            evidence: row.querySelector('.run-evidence')?.value || ''
        };
    });
}

// === Helper: nächsten Step-Index ermitteln (Gruppen ignorieren) ===
// ===== Funktion: nextStepNumber =====
// Zweck: siehe Inline-Kommentare im Funktionskörper.
function nextStepNumber() {
    const stepsOnly = (template?.steps || []).filter(s => (s.kind || s.type || 'step') === 'step');
    // Versuche numerischen Suffix aus "step-XYZ" zu lesen, sonst zähle Steps
    const nums = stepsOnly
        .map(s => String(s.id || ''))
        .map(id => {
            const m = id.match(/step-(\d+)/i);
            return m ? parseInt(m[1], 10) : null;
        })
        .filter(n => Number.isFinite(n));
    const base = nums.length ? Math.max(...nums) : stepsOnly.length;
    return base + 1;
}
// ===== Funktion: makeStepId =====
function makeStepId(n) {
    const num = String(Math.max(1, n)).padStart(3, '0');
    return `step-${num}`;
}

// === Helper: IDs streng sequentiell vergeben (Gruppen ignorieren) ===
// ===== Funktion: renumberSteps =====
// Zweck: siehe Inline-Kommentare im Funktionskörper.
function renumberSteps() {
    if (!template || !Array.isArray(template.steps)) return;
    let n = 1;
    template.steps.forEach(s => {
        const k = s.kind || s.type || 'step';
        if (k !== 'step') return;
        const id = `step-${String(n).padStart(3,'0')}`;
        s.id = id;
        n++;
    });
}
// ===== Funktion: ensureRenumberAndRender =====
function ensureRenumberAndRender() {
    renumberSteps();
    renderSteps(template.steps);
    recomputeGroupStyles();
}

// === Steps rendern ===
// ===== Funktion: renderSteps =====
// Zweck: siehe Inline-Kommentare im Funktionskörper.
// Rendert die Steps-Tabelle, erzeugt Zeilen & bindet Events (Pass/Fail/Kommentar).
function renderSteps(steps) {
    if (!els.stepsTableBody) return;
    els.stepsTableBody.innerHTML = '';
    let groupCollapsed = false;
    steps.forEach((s, idx) => {
        if ((s.kind || 'step') === 'group') {
            const trG = document.createElement('tr');
            trG.setAttribute('data-kind', 'group');
            trG.className = 'group-row';
            if (s.collapsed) trG.setAttribute('data-collapsed', '1');
            /* drag via handle only */
            // trG.setAttribute('draggable','true');
            trG.innerHTML = `
        <td colspan="4">
          <div class="step-head" style="grid-template-columns: 1fr auto auto;">
            <input type="text" class="tpl-group-title group-title" placeholder="Gruppen-Titel (z. B. Einrichtung des Moduls)" value="${escAttr(s.title||'')}">
            <div class="group-actions">
              <button type="button" class="btn btn-group-status" data-status="pass"    title="Alle in Gruppe: PASS">✅</button>
              <button type="button" class="btn btn-group-status" data-status="fail"    title="Alle in Gruppe: FAIL">❌</button>
              <button type="button" class="btn btn-group-status" data-status="skip"    title="Alle in Gruppe: SKIP">⏭️</button>
              <button type="button" class="btn btn-group-status" data-status="blocked" title="Alle in Gruppe: BLOCK">⛔</button>
              <button type="button" class="btn btn-toggle-group" title="Gruppe ein-/ausklappen"><span class="chev">${s.collapsed?'▸':'▾'}</span></button>
              <button type="button" class="btn btn-delete-group" title="Diese Gruppe löschen">🗑️</button>
            </div>
            <div class="drag-handle" draggable="true" title="Ziehen zum Verschieben">⋮⋮</div>
          </div>
        </td>`;
            els.stepsTableBody.appendChild(trG);
            groupCollapsed = !!s.collapsed;
            return;
        }
        const id = s.id || '';
        const tr = document.createElement('tr');
        tr.setAttribute('data-kind', 'step');
        /* drag via handle only */
        // tr.setAttribute('draggable','true');
        if (groupCollapsed) tr.setAttribute('data-hidden', '1');
        tr.innerHTML = `
      <td class="cell-step">
        <div class="step-head">
          <input type="text" class="tpl-id" value="${escAttr(id)}" />
          <textarea class="tpl-title" placeholder="Titel">${escHTML(s.title||'')}</textarea>
          <span class="req-pin" title="Pflichtschritt">${s.required ? '📌' : ''}</span>
          <button type="button" class="btn btn-delete-step" title="Diesen Step löschen">🗑️</button>
        
            <div class="drag-handle" draggable="true" title="Ziehen zum Verschieben">⋮⋮</div>
          </div>
        <label class="req-row"><input type="checkbox" class="tpl-required"${s.required?' checked':''}> required</label>
      </td>
      <td><textarea class="tpl-expected" placeholder="Erwartetes Verhalten">${escHTML(s.expected||'')}</textarea></td>
      <td>
        <select class="status" data-step="${escAttr(id)}">
          <option value="" ${!s.status ? 'selected':''}></option>
          <option value="pass" ${s.status==='pass'?'selected':''}>pass</option>
          <option value="fail" ${s.status==='fail'?'selected':''}>fail</option>
          <option value="skip" ${s.status==='skip'?'selected':''}>skip</option>
          <option value="blocked" ${s.status==='blocked'?'selected':''}>blocked</option>
        </select>
      </td>
      <td>
        <textarea class="run-comment" placeholder="Kommentar">${escHTML(s.comment||'')}</textarea>
        <input class="run-evidence" placeholder="Evidenz-URL" value="${escAttr(s.evidence||'')}">
      </td>`;
        els.stepsTableBody.appendChild(tr);
        updateStatusClass(tr.querySelector('select.status'));
        // nach jedem Render Schritt neu bewerten
        recomputeGroupStyles();
    });
}

// === Gruppenstatus einfärben ===
function recomputeGroupStyles() {
    try {
        const body = els.stepsTableBody;
        if (!body) return;
        const rows = [...body.querySelectorAll('tr')];
        // Collect groups with following step rows until next group
        let groups = [];
        let current = null;
        rows.forEach(r => {
            const kind = r.getAttribute('data-kind') || 'step';
            if (kind === 'group') {
                current = {
                    row: r,
                    steps: []
                };
                groups.push(current);
            } else if (current) {
                current.steps.push(r);
            }
        });
        groups.forEach(g => {
            g.row.classList.remove('group-ok', 'group-fail');
            const statuses = g.steps.map(tr => (tr.querySelector('select.status')?.value || '').toUpperCase());
            if (!statuses.length) return;
            const allPass = statuses.every(s => s === 'PASS');
            const anyFail = statuses.some(s => s === 'FAIL' || s === 'BLOCKED');
            if (allPass) g.row.classList.add('group-ok');
            else if (anyFail) g.row.classList.add('group-fail');
        });
    } catch (e) {
        console.warn('recomputeGroupStyles failed', e);
    }
}

function updateStatusClass(selectEl) {
    if (!selectEl) return;
    const tr = selectEl.closest('tr');
    ['st-pass', 'st-fail', 'st-skip', 'st-blocked'].forEach(c => selectEl.classList.remove(c));
    ['row-pass', 'row-fail', 'row-skip', 'row-blocked'].forEach(c => tr.classList.remove(c));
    const v = selectEl.value || '';
    if (!v) return;
    selectEl.classList.add('st-' + v);
    tr.classList.add('row-' + v);
}

// === Lauf/Template sammeln ===
// ===== Funktion: collectRun =====
function collectRun() {
    if (!template) return null;
    captureEditsIntoTemplate();
    return {
        name: template?.name || '',
        module: els.module.value,
        module_version: els.moduleVersion.value,
        pbx_version: els.pbxVersion.value,
        olm_nummer: els.olmNummer ? els.olmNummer.value : '',
        tester: els.tester.value,
        docbee_url: els.docbeeUrl.value,
        ts: new Date().toISOString(),
        steps: [...template.steps]
    };
}
// ===== Funktion: collectTemplateFromDOM =====
function collectTemplateFromDOM() {
    if (!template) return {
        name: '',
        module: '',
        module_version: '',
        pbx_version: '',
        olm_nummer: '',
        steps: []
    };
    captureEditsIntoTemplate();
    renumberSteps();
    return {
        name: template?.name || els.tplName?.textContent || '',
        module: els.module.value,
        module_version: els.moduleVersion.value,
        pbx_version: els.pbxVersion.value,
        olm_nummer: els.olmNummer ? els.olmNummer.value : '',
        steps: template.steps.map(s => {
            if ((s.kind || 'step') === 'group') return {
                type: 'group',
                title: s.title
            };
            return {
                type: 'step',
                id: s.id,
                title: s.title,
                expected: s.expected,
                required: !!s.required
            };
        })
    };
}
// ===== Funktion: checkRequired =====
function checkRequired(run) {
    const missing = run.steps.filter(s => (s.kind || 'step') === 'step' && s.required && !s.status);
    if (missing.length > 0) {
        alert("Folgende Pflichtschritte haben keinen Status:\n" +
            missing.map(s => `${s.id} – ${s.title}`).join("\n"));
        return false;
    }
    return true;
}

// === Exporte ===

// === Export All (DocBee, DB, PDF) ===
async function exportAll() {
    console.log('exportAll START');
    const run = collectRun();
    console.log('collectRun()', run);
    if (!run) {
        alert('Kein Template/keine Steps: bitte Template laden oder Steps anlegen.');
        return;
    }
    if (!checkRequired(run)) {
        console.log('checkRequired() failed');
        return;
    }
    renumberSteps();

    // 1) Push to DocBee using existing function (if token available)
    let pushedDocBee = null;
    try {
        if (window.DOCBEE_TOKEN && window.DOCBEE_TOKEN.length > 10) {
            pushedDocBee = await postToDocBee(true); // variant returning URL
            if (pushedDocBee && typeof pushedDocBee === 'string') {
                run.docbee_url = pushedDocBee;
            }
        }
    } catch (e) {
        console.warn('DocBee push failed:', e);
    }

    // 2) Generate PDF client-side
    const pdfBlob = await generatePdfBlob(run).catch(() => null);

    // 3) Upload to server (DB + PDF)
    const fd = new FormData();
    fd.append('run', JSON.stringify(run));
    if (pdfBlob) fd.append('pdf', pdfBlob, 'report.pdf');

    const res = await fetch('/api/export.php', {
        method: 'POST',
        body: fd
    });
    const raw = await res.text();
    let json;
    try {
        json = JSON.parse(raw);
    } catch {
        json = null;
    }
    console.log('Server response:', raw);
    if (!res.ok || !json || json.ok === false) {
        alert('Serverfehler beim Export:\n' + (json?.error || raw || ('HTTP ' + res.status)));
        return;
    }

    alert('Export fertig:\n' +
        (run.docbee_url ? ('DocBee: ' + run.docbee_url + '\n') : '') +
        (json.pdf_path ? ('PDF gespeichert: ' + json.pdf_path + '\n') : '') +
        ('Report-ID: ' + json.report_id));
}
// ========= jsPDF Loader (local + CDN) =========
// Lädt jsPDF (lokal -> CDN -> Unpkg) und optional autotable.
// Gibt true/false zurück, wirft NICHT.
async function ensureJsPdf() {
    function loadScript(url) {
        return new Promise((resolve, reject) => {
            const s = document.createElement('script');
            s.src = url;
            s.onload = resolve;
            s.onerror = reject;
            document.head.appendChild(s);
        });
    }
    async function tryLoad(urls) {
        for (const u of urls) {
            try {
                await loadScript(u);
            } catch (_) {}
            if (window.jspdf && window.jspdf.jsPDF) return true;
        }
        return !!(window.jspdf && window.jspdf.jsPDF);
    }

    // 1) jsPDF laden (lokal bevorzugt, dann CDN/Unpkg)
    if (!(window.jspdf && window.jspdf.jsPDF)) {
        await tryLoad([
            '/vendor/jspdf/jspdf.umd.min.js',
            'https://cdn.jsdelivr.net/npm/jspdf@2.5.1/dist/jspdf.umd.min.js',
            'https://unpkg.com/jspdf@2.5.1/dist/jspdf.umd.min.js'
        ]);
    }
    if (!(window.jspdf && window.jspdf.jsPDF)) return false;

    // 2) autotable nachladen, falls nicht vorhanden
    try {
        const test = new window.jspdf.jsPDF();
        if (typeof test.autoTable !== 'function') {
            await tryLoad([
                '/vendor/jspdf-autotable/jspdf.plugin.autotable.min.js',
                'https://cdn.jsdelivr.net/npm/jspdf-autotable@3.8.2/dist/jspdf.plugin.autotable.min.js',
                'https://unpkg.com/jspdf-autotable@3.8.2/dist/jspdf.plugin.autotable.min.js'
            ]);
        }
    } catch (_) {
        /* egal – PDF geht auch ohne autotable-Funktion (Fallback) */
    }

    return true;
}
async function generatePdfBlob(run) {
    // Lade jsPDF + AutoTable falls nötig (lokal → CDN/Unpkg). Fällt ansonsten sauber auf Text-Blob zurück.
    const pdfReady = await ensureJsPdf();
    if (!pdfReady) {
        const txt = 'QA Report\n' + JSON.stringify(run, null, 2);
        return new Blob([txt], {
            type: 'application/pdf'
        });
    }
    const {
        jsPDF
    } = window.jspdf;
    const doc = new jsPDF({
        orientation: 'landscape',
        unit: 'mm',
        format: 'a4',
        compress: true
    });
    // dynamic layout metrics for landscape A4
    const pageW = doc.internal.pageSize.getWidth();
    const pageH = doc.internal.pageSize.getHeight();
    const marginL = 14,
        marginR = 14;
    const xRight = pageW - marginR;
    const footerY = pageH - 13;

    // Farben (RGB, NICHT Hex!): "muted" war zuvor unsichtbar – jetzt korrekt
    const muted = [102, 102, 102];
    const headFill = [242, 242, 242];
    const ok = [0, 140, 0],
        fail = [200, 0, 0],
        skip = [160, 160, 0];

    // Optionales Logo oben rechts – proportional in 26×12 mm Box, rechtsbündig
    let logo = null;
    try {
        logo = await imgToDataURL('logo_light.png');
    } catch {}

    if (logo) {
        // 1) Bildabmessungen ermitteln (erst jsPDF, dann Fallback über <img>)
        let iw = 0,
            ih = 0;
        try {
            const props = doc.getImageProperties ? doc.getImageProperties(logo) : null;
            if (props) {
                iw = props.width || 0;
                ih = props.height || 0;
            }
        } catch {}
        if (!(iw && ih)) {
            await new Promise((resolve) => {
                const img = new Image();
                img.onload = () => {
                    iw = img.naturalWidth;
                    ih = img.naturalHeight;
                    resolve();
                };
                img.onerror = () => resolve();
                img.src = logo;
            });
        }

        // 2) Proportional in die Box einpassen
        const maxW = 26,
            maxH = 12;
        const ratio = (ih && iw) ? (ih / iw) : (10 / 26);
        let w = maxW,
            h = w * ratio;
        if (h > maxH) {
            h = maxH;
            w = h / ratio;
        }

        // 3) Rechtsbündig relativ zur Nutzkante (xRight)
        const x = xRight - w;
        const y = 7.8;

        try {
            doc.addImage(logo, 'PNG', x, y, w, h, undefined, 'FAST');
        } catch {}
    }


    // Titelzeile
    doc.setFont('helvetica', 'bold');
    doc.setFontSize(18);
    doc.setTextColor(0, 0, 0);
    doc.text('QA Report', 14, 16);
    doc.setDrawColor(0, 0, 0);
    doc.setLineWidth(0.2);
    doc.line(marginL, 18, xRight, 18);

    // Metadaten mit Labels (sichtbar!)
    const meta = [
        ['Modul', sanitizeText(run.module)],
        ['Modul-Version', sanitizeText(run.module_version)],
        ['PBX-Version', sanitizeText(run.pbx_version)],
        ['OLM-Nummer', sanitizeText(run.olm_nummer)],
        ['Tester', sanitizeText(run.tester)],
        ['DocBee', sanitizeText(run.docbee_url || '-')],
    ];
    let y = 24;
    doc.setFontSize(10);
    meta.forEach(([k, v]) => {
        doc.setFont('helvetica', 'bold');
        doc.setTextColor(...muted);
        doc.text(k + ':', 14, y);
        doc.setFont('helvetica', 'normal');
        doc.setTextColor(0, 0, 0);
        doc.text(v || '—', 50, y);
        y += 6;
    });

    // Zeilen aus Steps bauen
    const rows = [];
    let i = 0,
        group = null;
    (run.steps || []).forEach(s => {
        if ((s.kind || 'step') === 'group') {
            group = sanitizeText(s.title);
            return;
        }
        i++;
        rows.push({
            nr: i,
            group: sanitizeText(group || '–'),
            id: sanitizeText(s.id),
            title: sanitizeText(s.title),
            expected: sanitizeText(s.expected),
            status: sanitizeText((s.status || '').toLowerCase()),
            comment: sanitizeText(s.comment),
        });
    });

    const body = rows.map(r => [r.nr, r.group, r.id, r.title, r.expected, r.status, r.comment]);
    const startY = Math.max(y + 4, 24);

    // Tabelle (autoTable)
    // @ts-ignore
    doc.autoTable({
        startY,
        tableWidth: pageW - (marginL + marginR),
        head: [
            ['#', 'Gruppe', 'Step-ID', 'Titel', 'Erwartet', 'Status', 'Kommentar']
        ],
        body,
        styles: {
            font: 'helvetica',
            fontSize: 9,
            cellPadding: 2,
            overflow: 'linebreak',
            valign: 'top'
        },
        headStyles: {
            fillColor: headFill,
            textColor: [0, 0, 0],
            halign: 'left'
        },
        columnStyles: {
            0: {
                halign: 'right',
                cellWidth: 8
            },
            1: {
                cellWidth: 36
            },
            2: {
                cellWidth: 24
            },
            3: {
                cellWidth: 62
            },
            4: {
                cellWidth: 76
            },
            5: {
                cellWidth: 20,
                halign: 'left'
            },
            6: {
                cellWidth: 36
            }
        },
        didParseCell: (d) => {
            // Normalize every cell fragment to PDF-safe ASCII/Latin-1 and map arrows
            if (d && d.cell) {
                const arr = Array.isArray(d.cell.text) ? d.cell.text : [String(d.cell.text ?? '')];
                d.cell.text = arr.map(t => toPdfSafe(replaceSymbols(String(t))));
            }
            if (d.section === 'body') {
                // Status einfärben
                if (d.column.index === 5) {
                    const val = String(d.cell.raw || '').toLowerCase();
                    if (val === 'pass') d.cell.styles.textColor = ok;
                    else if (val === 'fail') d.cell.styles.textColor = fail;
                    else if (val === 'skip' || val === 'na') d.cell.styles.textColor = skip;
                }
                // "Erwartet" als Monospace + leicht grauer Hintergrund
                if (d.column.index === 4) {
                    d.cell.styles.font = 'courier';
                    d.cell.styles.fillColor = [250, 250, 250];
                }
            }
        },
        margin: {
            left: marginL,
            right: marginR
        },
        pageBreak: 'auto'
    });

    // Footer: Seitenzahlen & Zeitstempel
    const pageCount = doc.getNumberOfPages();
    const ts = new Date().toLocaleString();
    for (let p = 1; p <= pageCount; p++) {
        doc.setPage(p);
        doc.setFont('helvetica', 'normal');
        doc.setFontSize(8);
        doc.setTextColor(...muted);
        doc.text(`Seite ${p} / ${pageCount}`, xRight, footerY, {
            align: 'right'
        });
        doc.text(ts, marginL, footerY);
    }

    return doc.output('blob');
}

// === Template als YAML exportieren und zu GitLab pushen ===
// ===== Funktion: exportTemplateYAML =====
async function exportTemplateYAML() {
    if (!GITLAB.token) {
        alert('Kein GitLab-Token konfiguriert. Bitte Token setzen.');
        return;
    }

    const tpl = collectTemplateFromDOM();
    if (!tpl.module || !tpl.module_version || !tpl.pbx_version) {
        alert('Bitte Modul, Modul-Version und PBX-Version angeben.');
        return;
    }

    let yml = '';
    if (window.jsyaml && jsyaml.dump) {
        yml = jsyaml.dump(tpl, {
            lineWidth: 100
        });
    } else {
        yml += `name: "${tpl.name||''}"\n`;
        yml += `module: "${tpl.module||''}"\n`;
        yml += `module_version: "${tpl.module_version||''}"\n`;
        yml += `pbx_version: "${tpl.pbx_version||''}"\n`;
        yml += `olm_nummer: "${tpl.olm_nummer||''}"\n`;
        yml += `steps:\n`;
        tpl.steps.forEach(s => {
            if (s.type === 'group') {
                yml += `  - type: "group"\n`;
                yml += `    title: "${(s.title||'').replace(/"/g,'\\"')}"\n`;
            } else {
                yml += `  - type: "step"\n`;
                yml += `    id: "${s.id||''}"\n`;
                yml += `    title: "${(s.title||'').replace(/"/g,'\\"')}"\n`;
                yml += `    expected: "${(s.expected||'').replace(/"/g,'\\"')}"\n`;
                yml += `    required: ${s.required ? 'true':'false'}\n`;
            }
        });
    }

    // Keep original filename behaviour: module name only
    const filename = `${safeName(tpl.module)}.yaml`;
    const filePath = GITLAB.path + '/' + filename;

    // Keep base64 encoding but send the proper `encoding` flag so GitLab
    // decodes and stores the real YAML. The prior bug encoded the content
    // but did not set `encoding`, so GitLab stored the base64 string.
    const content = btoa(unescape(encodeURIComponent(yml)));

    try {
        if (els.statusTag) els.statusTag.textContent = 'GitLab: Pushe Template...';

        const url = `${GITLAB.host}/api/v4/projects/${encodeURIComponent(GITLAB.projectId)}/repository/files/${encodeURIComponent(filePath)}`;
        
        // Try to get the file first to see if it exists
        const checkRes = await fetch(url + `?ref=${encodeURIComponent(GITLAB.ref)}`, {
            headers: glHeaders()
        });
        
        const method = checkRes.ok ? 'PUT' : 'POST';
        const commitMessage = checkRes.ok ? 
            `Update QA template for ${tpl.module} ${tpl.module_version}` :
            `Add QA template for ${tpl.module} ${tpl.module_version}`;

        const response = await fetch(url, {
            method,
            headers: {
                ...glHeaders(),
                'Content-Type': 'application/json',
            },
            body: JSON.stringify({
                branch: GITLAB.ref,
                content: content,
                encoding: 'base64',
                commit_message: commitMessage,
            })
        });

        if (!response.ok) {
            throw new Error(`HTTP ${response.status}`);
        }

        const result = await response.json();
        if (els.statusTag) els.statusTag.textContent = 'GitLab: Template erfolgreich gepusht';
        alert(`Template wurde erfolgreich zu GitLab gepusht:\n${filePath}`);
    } catch (error) {
        console.error('GitLab push failed:', error);
        if (els.statusTag) els.statusTag.textContent = 'GitLab: Fehler beim Pushen';
        alert('Fehler beim Pushen zu GitLab: ' + error.message);
    }
}

// === JSON speichern/laden ===
els.btnSaveJSON && els.btnSaveJSON.addEventListener('click', () => {
    const run = collectRun();
    if (!run) return;
    if (!checkRequired(run)) return;
    renumberSteps();
    const base = `qa-run-${safeName(run.module)}-${safeName(run.module_version)}-${safeName(run.pbx_version)}`;
    download(JSON.stringify(run, null, 2), `${base}.json`, 'application/json');
});

// Rebind: entfernt ALLE alten Listener am File-Input und setzt den neuen
document.addEventListener('DOMContentLoaded', () => {
    let inp = document.getElementById('loadJSON');
    if (!inp) return;
    const clone = inp.cloneNode(true); // droppt alle alten Listener
    inp.replaceWith(clone);
    els.loadJSON = clone; // unsere globale Referenz aktualisieren

    clone.addEventListener('change', async (e) => {
        const f = e.target.files?.[0];
        if (!f) return;
        const txt = await f.text();
        const name = String(f.name || '');
        const ext = (name.split('.').pop() || '').toLowerCase();
        const looksLikeJSON = /^[\s\r\n]*[{\[]/.test(txt);

        function applyRun(run) {
            template = {
                name: run.name || '',
                steps: (run.steps || []).map(s => {
                    const k = s.kind || s.type || 'step';
                    if (k === 'group') return {
                        kind: 'group',
                        title: s.title || ''
                    };
                    return {
                        kind: 'step',
                        id: s.id,
                        title: s.title,
                        expected: s.expected,
                        required: !!s.required,
                        status: s.status || '',
                        comment: s.comment || '',
                        evidence: s.evidence || ''
                    };
                })
            };
            if (els.tplName) els.tplName.textContent = run.name || '—';
            if (els.module) els.module.value = run.module || '';
            if (els.moduleVersion) els.moduleVersion.value = run.module_version || '';
            if (els.pbxVersion) els.pbxVersion.value = run.pbx_version || '';
            if (els.tester) els.tester.value = run.tester || '';
            if (els.docbeeUrl) els.docbeeUrl.value = run.docbee_url || '';
            if (els.olmNummer) els.olmNummer.value = run.olm_nummer || '';
            renderSteps(template.steps);
            recomputeGroupStyles();
            if (els.statusTag) els.statusTag.textContent = 'Lauf geladen';
            updateAutosave();
        }

        function applyTemplateOnly(tplObj) {
            template = {
                name: tplObj.name || '',
                steps: (tplObj.steps || []).map(s => {
                    const k = s.kind || s.type || 'step';
                    if (k === 'group') return {
                        kind: 'group',
                        title: s.title || ''
                    };
                    return {
                        kind: 'step',
                        id: s.id || '',
                        title: s.title || '',
                        expected: s.expected || '',
                        required: !!s.required,
                        status: '',
                        comment: '',
                        evidence: ''
                    };
                })
            };
            if (els.tplName) els.tplName.textContent = template.name || '—';
            if (els.module) els.module.value = tplObj.module || '';
            if (els.moduleVersion) els.moduleVersion.value = tplObj.module_version || '';
            if (els.pbxVersion) els.pbxVersion.value = tplObj.pbx_version || '';
            if (els.olmNummer) els.olmNummer.value = tplObj.olm_nummer || '';
            renderSteps(template.steps);
            recomputeGroupStyles();
            if (els.statusTag) els.statusTag.textContent = 'Template geladen';
            updateAutosave();
        }

        try {
            if (ext === 'json' || looksLikeJSON) {
                try {
                    const run = JSON.parse(txt);
                    applyRun(run);
                } catch (jsonErr) {
                    if (window.jsyaml && jsyaml.load) {
                        const tplObj = jsyaml.load(txt) || {};
                        applyTemplateOnly(tplObj);
                    } else {
                        throw new Error('JSON ungültig und YAML nicht verfügbar: ' + jsonErr.message);
                    }
                }
            } else {
                if (!(window.jsyaml && jsyaml.load)) throw new Error('YAML-Unterstützung (js-yaml) fehlt.');
                const tplObj = jsyaml.load(txt) || {};
                applyTemplateOnly(tplObj);
            }
        } catch (err) {
            alert('Fehler beim Laden: ' + (err?.message || err));
        }
    });
});


// === Ticket-ID aus URL ===
// ===== Funktion: extractTicketId =====
// Zweck: siehe Inline-Kommentare im Funktionskörper.
function extractTicketId(u) {
    const m = String(u || "").match(/(?:\/ticket\/show\/|\/tickets\/)(\d+)/i);
    return m ? m[1] : null;
}

// === DocBee Calls ===
async function postJSON(url, body) {
    const r = await fetch(url, {
        method: "POST",
        headers: {
            "Authorization": "Bearer " + DOCBEE_TOKEN,
            "Content-Type": "application/json; charset=utf-8",
            "Accept": "application/json"
        },
        body: JSON.stringify(body)
    });
    const text = await r.text().catch(() => "-");
    console.log("[DocBee][POST]", url, body);
    console.log("[DocBee][RES]", r.status, text.slice(0, 700));
    if (r.status === 401) {
        try {
            updateTokenBadge("bad");
        } catch (_) {}
    }
    return {
        ok: r.ok,
        status: r.status,
        text
    };
}

// --- Helpers (DocBee) -------------------------------------------------
if (typeof sleep !== 'function') {
    function sleep(ms) {
        return new Promise(r => setTimeout(r, ms));
    }
}
if (typeof getJSON !== 'function') {
    async function getJSON(url) {
        const r = await fetch(url, {
            method: "GET",
            headers: {
                "Authorization": "Bearer " + DOCBEE_TOKEN,
                "Accept": "application/json"
            }
        });
        const text = await r.text().catch(() => "-");
        let json = null;
        try {
            json = JSON.parse(text);
        } catch {}
        console.log("[DocBee][GET]", url);
        console.log("[DocBee][RES]", r.status, text.slice(0, 700));
        return {
            ok: r.ok,
            status: r.status,
            json,
            text
        };
    }
}

// --- DocBee-Helper global (hoisted) ---
function sleep(ms) {
    return new Promise(r => setTimeout(r, ms));
}

async function getJSON(url) {
    const r = await fetch(url, {
        method: "GET",
        headers: {
            "Authorization": "Bearer " + DOCBEE_TOKEN,
            "Accept": "application/json"
        }
    });
    const text = await r.text().catch(() => "-");
    let json = null;
    try {
        json = JSON.parse(text);
    } catch {}
    console.log("[DocBee][GET]", url);
    console.log("[DocBee][RES]", r.status, text.slice(0, 700));
    return {
        ok: r.ok,
        status: r.status,
        json,
        text
    };
}

async function putJSON(url, body) {
    const r = await fetch(url, {
        method: "PUT",
        headers: {
            "Authorization": "Bearer " + DOCBEE_TOKEN,
            "Content-Type": "application/json; charset=utf-8",
            "Accept": "application/json"
        },
        body: JSON.stringify(body)
    });
    const text = await r.text().catch(() => "-");
    console.log("[DocBee][PUT]", url, body);
    console.log("[DocBee][RES]", r.status, text.slice(0, 700));
    let json = null;
    try {
        json = JSON.parse(text);
    } catch {}
    return {
        ok: r.ok,
        status: r.status,
        json,
        text
    };
}

// Vorgangs-Status robust auf vorherige ID zurücksetzen (primär ticketStatus, Fallback status)
async function restoreTicketStatus(ticketId, prevStatusId) {
    try {
        const maxLoops = 5; // ~30s
        let stable = 0;
        for (let i = 0; i < maxLoops; i++) {
            await sleep(200);
            const tCur = await getJSON(`${DOCBEE_BASEURL}/restApi/v1/ticket/${ticketId}?fields=ticketStatus`);
            const curId = tCur?.json?.ticketStatus ?? null;
            if (curId == null) break;
            if (curId !== prevStatusId) {
                let r = await putJSON(`${DOCBEE_BASEURL}/restApi/v1/ticket/${ticketId}`, {
                    ticketStatus: {
                        id: prevStatusId
                    }
                });
                if (!r.ok) {
                    await putJSON(`${DOCBEE_BASEURL}/restApi/v1/ticket/${ticketId}`, {
                        status: {
                            id: prevStatusId
                        }
                    });
                }
                stable = 0;
            } else {
                stable++;
                if (stable >= 2) break;
            }
        }
    } catch (e) {
        console.warn("[DocBee] restoreTicketStatus failed:", e);
    }
}


if (typeof putJSON !== 'function') {
    async function putJSON(url, body) {
        const r = await fetch(url, {
            method: "PUT",
            headers: {
                "Authorization": "Bearer " + DOCBEE_TOKEN,
                "Content-Type": "application/json; charset=utf-8",
                "Accept": "application/json"
            },
            body: JSON.stringify(body)
        });
        const text = await r.text().catch(() => "-");
        console.log("[DocBee][PUT]", url, body);
        console.log("[DocBee][RES]", r.status, text.slice(0, 700));
        let json = null;
        try {
            json = JSON.parse(text);
        } catch {}
        return {
            ok: r.ok,
            status: r.status,
            json,
            text
        };
    }
}

async function restoreTicketStatus(ticketId, prevStatusId) {
    try {
        const maxLoops = 30; // ~30s
        let stable = 0;
        for (let i = 0; i < maxLoops; i++) {
            await sleep(200);
            const tCur = await getJSON(`${DOCBEE_BASEURL}/restApi/v1/ticket/${ticketId}?fields=ticketStatus.id,ticketStatus.name,status.id,status.name`);
            const curId = tCur?.json?.ticketStatus?.id ?? tCur?.json?.status?.id ?? null;
            if (curId == null) break;
            if (curId !== prevStatusId) {
                const r = await setTicketStatus(ticketId, prevStatusId);
                console.log("[DocBee][restore] result:", r);
                stable = 0; // nach Setzen erneut beobachten
            } else {
                stable++;
                if (stable >= 2) break; // 2x stabil reicht
            }
        }
    } catch (e) {
        console.warn("[DocBee] restoreTicketStatus failed:", e);
    }
}


// Status-Setter 
async function setTicketStatus(ticketId, statusId) {
    const base = `${DOCBEE_BASEURL}/restApi/v1`;
    const tries = [
        () => putJSON(`${base}/ticket/${ticketId}`, {
            ticketStatus: statusId
        }),
    ];
    let last = null;
    for (let i = 0; i < tries.length; i++) {
        try {
            const r = await tries[i]();
            console.log(`[DocBee][STATUS][try ${i}]`, r?.status, r?.ok);
            if (r && r.ok) return {
                ok: true,
                variant: i,
                status: r.status,
                text: r.text
            };
            last = r;
        } catch (e) {
            console.warn(`[DocBee][STATUS][try ${i}] exception`, e);
            last = {
                ok: false,
                status: 0,
                text: String(e)
            };
        }
    }
    return last || {
        ok: false
    };
}


// ===== Funktion: appendResultLink =====
// Zweck: siehe Inline-Kommentare im Funktionskörper.

const DOCBEE_UI_BASE = DOCBEE_BASEURL; // feste Instanzbasis für UI-Links

function appendResultLink(createdJSON) {
    let link = null;
    try {
        const j = JSON.parse(createdJSON || "{}");
        link = j?.link || null;
    } catch {}

    // Ticket-ID aus dem Eingabefeld (falls vorhanden) extrahieren
    const ticketId = extractTicketId(els.docbeeUrl?.value || '');

    // Ziel-URL bestimmen:

    let url = `${DOCBEE_UI_BASE.replace(/\/+$/,'')}/ticket/show/${ticketId}`;

    if (!url) return;

    const hint = document.createElement('div');
    hint.className = 'docbee-hint';
    hint.innerHTML = `✅ Angelegt: <a href="${url}" target="_blank" rel="noopener">${url}</a>`;
    document.querySelector('.actions')?.appendChild(hint);
}

// ===== Funktion: formatDocBeeMessage =====
// Zweck: siehe Inline-Kommentare im Funktionskörper.
function formatDocBeeMessage(run) {
    const pad = (s, n) => (s || '').length > n ? (s || '').slice(0, n - 1) + '…' : (s || '').padEnd(n, ' ');
    const fmtDate = new Date(run.ts).toLocaleString('de-DE');

    // Kurz-Summary (nur echte Steps zählen)
    const counts = {
        pass: 0,
        fail: 0,
        skip: 0,
        blocked: 0
    };
    (run.steps || []).forEach(s => {
        const k = s.kind || s.type || 'step';
        if (k !== 'step') return;
        if (counts[s.status] !== undefined) counts[s.status]++;
    });
    const summary = `✅ ${counts.pass} | ❌ ${counts.fail} | ⏭️ ${counts.skip} | ⛔ ${counts.blocked}`;

    // Kopf + Metadaten (out VOR jeglicher Nutzung initialisieren)
    let out = '';
        if (counts.fail === 0 && counts.blocked === 0) {
        out += `✅ QA Bestanden\n`;
    } else {
        out += `❌ QA nicht Bestanden\n`;
    }
    out += `Modul:        ${run.module || ''}\n`;
    out += `Modul-Version:${run.module_version || ''}\n`;
    out += `PBX-Version:  ${run.pbx_version || ''}\n`;
    out += `Tester:       ${run.tester || ''}\n`;
    if (run.docbee_url) out += `Ticket:       ${run.docbee_url}\n`;
    out += `Datum:        ${fmtDate}\n\n`;
    out += `Übersicht:    ${summary}\n\n`;
    out += `\n`;

    // Tabelle (monospace-geeignet)
    const SEP = '─────────────────────────────────────────────────────────────────────';
    out += `${SEP}\n`;
    out += `${pad('Schritt', 12)} ${pad('Status', 7)} ${pad('Titel', 52)}\n`;
    out += `${SEP}\n`;

    (run.steps || []).forEach(s => {
        const k = s.kind || s.type || 'step';
        if (k === 'group') {
            out += `\n## ${s.title || ''}\n\n`;
            return;
        }
        const st = (s.status || '').toUpperCase(); // PASS/FAIL/SKIP/BLOCKED/…
        const stShort = st === 'BLOCKED' ? 'BLOCK' : st;
        const SMAP = {
            pass: '✅',
            fail: '❌',
            skip: '⏭️',
            blocked: '⛔'
        };
        const stLabel = (SMAP[(s.status || '').toLowerCase()] ?
            SMAP[(s.status || '').toLowerCase()] + ' ' :
            '') + stShort;
        const req = s.required ? '📌 ' : '';
        out += `${pad(s.id || '', 12)} ${pad(stLabel, 9)} ${pad(req + (s.title || ''), 50)}\n`;
        if (s.expected) out += ` • Erwartet: ${s.expected}\n`;
        if (s.comment) out += `  • Kommentar: ${s.comment}\n`;
        if (s.evidence) out += `  • Evidenz: ${s.evidence}\n`;
    });
    out += `${SEP}\n`;
    out += `Legende: PASS=✅, FAIL=❌, SKIP=⏭️, BLOCK=⛔, PFLICHTSCHRITT=📌 \n`;
    return out;
}


async function postToDocBee() {
    const run = collectRun();
    if (!run) return;
    if (!checkRequired(run)) return;

    const ticketId = extractTicketId(els.docbeeUrl?.value || '');
    if (!ticketId) {
        alert("Keine Ticket-ID in der DocBee-URL gefunden.");
        return;
    }
    if (!hasToken()) {
        alert("Kein API-Token konfiguriert.");
        return;
    }

    // Kommentarinhalt (Markdown)
    const content = formatDocBeeMessage(run);

    // alten Vorgangs-Status holen (Vorgangs-Status = ticketStatus)
    const tGet = await getJSON(`${DOCBEE_BASEURL}/restApi/v1/ticket/${ticketId}?fields=ticketStatus`);
    const prevStatusId = tGet?.json?.ticketStatus ?? null;

    // Busy-State
    els.btnPushDocBee?.setAttribute('disabled', '');
    els.btnPushDocBee?.classList.add('is-busy');

    try {

        // *** Message posten (muss Message sein) ***
        const url = `${DOCBEE_BASEURL}/restApi/v1/ticket/${ticketId}/message`;
        const rMsg = await postJSON(url, {
            content: content,
            subject: `QA Report ${ticketId}`,
            internal: true,
            hidden: false
        });

        if (rMsg.ok) {
            // kurze Wartezeit, dann Status ggf. zurücksetzen (gegen Auto-"Antwort erhalten")
            await sleep(400);
            if (prevStatusId != null) {
                await restoreTicketStatus(ticketId, prevStatusId);
            }
            appendResultLink(rMsg.text);
            //alert("Nachricht im Ticket angelegt (Status beibehalten)."); // Debug Message anzeigen
            return;
        } 


        if (ENABLE_FALLBACK_NOTE) {
            const rNote = await postJSON(`${DOCBEE_BASEURL}/restApi/v1/note`, {
                note: {
                    ticket: {
                        id: Number(ticketId)
                    },
                    subject: `QA Report ${ticketId}`,
                    text: content,
                    internal: false
                }
            });
            if (rNote.ok) {
                appendResultLink(rNote.text);
                alert("Notiz angelegt (Fallback).");
                return;
            }
            alert(`Fehler: MSG ${rMsg.status}, NOTE ${rNote.status}`);
        } else {
            alert(`Nachricht fehlgeschlagen (Status ${rMsg.status}). Details siehe Konsole.`);
        }
    } catch (e) {
        alert("DocBee-Request fehlgeschlagen: " + String(e));
    } finally {
        els.btnPushDocBee?.removeAttribute('disabled');
        els.btnPushDocBee?.classList.remove('is-busy');
    }
}


// === Token Badge ===
function updateTokenBadge(state) {
    const el = els.docbeeTokenStatus;
    if (!el) return;
    const has = !!(typeof DOCBEE_TOKEN !== 'undefined' && String(DOCBEE_TOKEN || '').trim().length > 10);
    el.classList.remove('ok', 'bad', 'warn');
    if (state === 'bad') {
        el.textContent = 'DocBee: ungültig/401';
        el.classList.add('bad');
        return;
    }
    if (state === 'ok' || (state === undefined && has)) {
        el.textContent = 'DocBee: Token gesetzt';
        el.classList.add('ok');
        return;
    }
    el.textContent = 'DocBee: fehlt';
    el.classList.add('warn');
}

// === Events ===
els.yamlFile && els.yamlFile.addEventListener('change', async (e) => {
    const f = e.target.files?.[0];
    if (!f) return;
    const txt = await f.text();
    try {
        loadYAMLText(txt);
    } catch (err) {
        alert('Vorlage konnte nicht geladen werden: ' + err.message);
    }
});
els.btnAddStep && els.btnAddStep.addEventListener('click', () => {
    if (!template) template = {
        name: 'Neues Template',
        steps: []
    };
    captureEditsIntoTemplate();
    template.steps.push({
        kind: 'step',
        id: '',
        title: '',
        expected: '',
        required: true,
        status: '',
        comment: '',
        evidence: ''
    });
    ensureRenumberAndRender();
});
els.btnAddGroup && els.btnAddGroup.addEventListener('click', () => {
    if (!template) template = {
        name: 'Neues Template',
        steps: []
    };
    captureEditsIntoTemplate();
    template.steps.push({
        kind: 'group',
        title: 'Neue Gruppe',
        collapsed: false
    });
    ensureRenumberAndRender();
});
els.stepsTableBody && els.stepsTableBody.addEventListener('click', (e) => {
    const btnStep = e.target.closest('.btn btn-delete-step, .btn-delete-step');
    const btnGroup = e.target.closest('.btn btn-delete-group, .btn-delete-group');
    const btnToggle = e.target.closest('.btn btn-toggle-group, .btn-toggle-group');
    const btnMass = e.target.closest('.btn btn-group-status, .btn-group-status');
    if (!btnStep && !btnGroup && !btnToggle && !btnMass) return;
    captureEditsIntoTemplate();
    const tr = e.target.closest('tr');
    const idx = [...els.stepsTableBody.children].indexOf(tr);
    if (idx < 0) return;
    if (btnToggle) {
        if ((template.steps[idx] || {}).kind === 'group') {
            template.steps[idx].collapsed = !template.steps[idx].collapsed;
            ensureRenumberAndRender();
        }
        return;
    }

    if (btnMass && (template.steps[idx] || {}).kind === 'group') {
        const status = (btnMass.getAttribute('data-status') || '').toLowerCase();
        if (!['pass', 'fail', 'skip', 'blocked'].includes(status)) return;
        // Schritte bis zur nächsten Gruppe finden
        let start = idx + 1;
        let end = template.steps.length;
        for (let i = start; i < template.steps.length; i++) {
            const k = (template.steps[i].kind || template.steps[i].type || 'step');
            if (k === 'group') {
                end = i;
                break;
            }
        }
        // Warnung, falls überschrieben würde
        const willOverwrite = template.steps.slice(start, end)
            .some(s => (s.kind || 'step') === 'step' && s.status && s.status !== status);
        if (willOverwrite) {
            const ok = confirm(`Einige Schritte in dieser Gruppe haben bereits einen anderen Status.\nAlle auf ${status.toUpperCase()} setzen?`);
            if (!ok) return;
        }
        for (let i = start; i < end; i++) {
            if ((template.steps[i].kind || 'step') !== 'step') continue;
            template.steps[i].status = status;
        }
        ensureRenumberAndRender();
        return;
    }

    // löschen (mit Warnung wenn Gruppe und nicht leer)
    if (btnGroup && (template.steps[idx] || {}).kind === 'group') {
        // zähle Steps bis zur nächsten Gruppe
        let cnt = 0;
        for (let i = idx + 1; i < template.steps.length; i++) {
            const k = (template.steps[i].kind || template.steps[i].type || 'step');
            if (k === 'group') break;
            if (k === 'step') cnt++;
        }
        if (cnt > 0) {
            const ok = confirm(`Diese Gruppe enthält ${cnt} Schritt(e).\nGruppe wirklich löschen? Die Schritte bleiben bestehen und werden nicht gelöscht.`);
            if (!ok) return;
        }
    }
    template.steps.splice(idx, 1);
    ensureRenumberAndRender();
});

// === GitLab Events ===
if (els.gitlabTplSelect) els.gitlabTplSelect.addEventListener('change', async (e) => {
    const p = e.target.value;
    if (!p) return;
    try {
        els.gitlabTplStatus && (els.gitlabTplStatus.textContent = "GitLab: lade Datei…");
        const y = await fetchGitlabFileRaw(p);
        loadYAMLText(y);
        els.gitlabTplStatus && (els.gitlabTplStatus.textContent = `GitLab: geladen – ${p.split('/').pop()}`);
    } catch (err) {
        els.gitlabTplStatus && (els.gitlabTplStatus.textContent = "GitLab: Fehler");
        console.error("[GitLab] raw failed:", err);
        alert("Vorlage aus GitLab konnte nicht geladen werden: " + err.message);
    }
});

if (els.btnGitlabReload) els.btnGitlabReload.addEventListener('click', (e) => {
    e.preventDefault();
    populateGitlabDropdown();
});

// === Drag & Drop (Rows verschieben, inkl. Gruppen) ===
let dragIndex = -1;

// ===== Funktion: rowIndexOf =====
// Zweck: siehe Inline-Kommentare im Funktionskörper.
function rowIndexOf(target) {
    const tr = target.closest('tr');
    if (!tr) return -1;
    return [...els.stepsTableBody.children].indexOf(tr);
}
els.stepsTableBody && els.stepsTableBody.addEventListener('dragstart', (e) => {
    if (!e.target.closest('.drag-handle')) {
        e.preventDefault();
        return;
    }
    const i = rowIndexOf(e.target);
    if (i < 0) return;
    dragIndex = i;
    e.dataTransfer?.setData('text/plain', String(i));
    if (e.dataTransfer) e.dataTransfer.effectAllowed = 'move';
    const row = e.target.closest('tr') || e.target;
    e.dataTransfer?.setDragImage?.(row, 16, 16);
    row.classList.add('dragging');
});
els.stepsTableBody && els.stepsTableBody.addEventListener('dragend', (e) => {
    const tr = e.target.closest('tr');
    if (tr) tr.classList.remove('dragging');
    [...els.stepsTableBody.children].forEach(r => {
        r.classList.remove('drag-over');
        r.removeAttribute('data-pos');
    });
    dragIndex = -1;
});
els.stepsTableBody && els.stepsTableBody.addEventListener('dragover', (e) => {
    e.preventDefault();
    const tr = e.target.closest('tr');
    if (!tr) return;
    [...els.stepsTableBody.children].forEach(r => {
        r.classList.remove('drag-over');
        r.removeAttribute('data-pos');
    });
    tr.classList.add('drag-over');
    const rect = tr.getBoundingClientRect();
    const pos = (e.clientY - rect.top) < rect.height / 2 ? 'before' : 'after';
    tr.setAttribute('data-pos', pos);
});
els.stepsTableBody && els.stepsTableBody.addEventListener('drop', (e) => {
    e.preventDefault();
    captureEditsIntoTemplate();
    const from = dragIndex >= 0 ? dragIndex : parseInt(e.dataTransfer?.getData('text/plain') || '-1', 10);
    const tr = e.target.closest('tr');
    if (from < 0 || !tr) return;
    const toBase = rowIndexOf(tr);
    const pos = tr.getAttribute('data-pos') || 'after';
    let to = toBase + (pos === 'after' ? 1 : 0);
    if (to === from || to - 1 === from) {
        renderSteps(template.steps);
        recomputeGroupStyles();
        return;
    }
    // Element verschieben
    const item = template.steps.splice(from, 1)[0];
    if (to > from) to--; // nach dem Entfernen verschiebt sich Index
    template.steps.splice(to, 0, item);
    ensureRenumberAndRender();
});

document.getElementById('stepsTable')?.addEventListener('change', (e) => {
    if (e.target && e.target.matches('select.status')) {
        updateStatusClass(e.target);
        recomputeGroupStyles();
    }
});

els.btnExportMD && els.btnExportMD.addEventListener('click', exportMD);
els.btnExportCSV && els.btnExportCSV.addEventListener('click', exportCSV);
els.btnPrintPDF && els.btnPrintPDF.addEventListener('click', printPDF);
els.btnExportTemplateYAML && els.btnExportTemplateYAML.addEventListener('click', exportTemplateYAML);

els.btnSaveJSON && els.btnSaveJSON.addEventListener('click', () => {
    const run = collectRun();
    if (!run) return;
    if (!checkRequired(run)) return;
    const base = `qa-run-${safeName(run.module)}-${safeName(run.module_version)}-${safeName(run.pbx_version)}`;
    download(JSON.stringify(run, null, 2), `${base}.json`, 'application/json');
});
els.loadJSON && els.loadJSON.addEventListener('change', async (e) => {
    const f = e.target.files?.[0];
    if (!f) return;
    const txt = await f.text();
    try {
        const run = JSON.parse(txt);
        template = {
            name: run.name || '',
            steps: (run.steps || []).map(s => ({
                id: s.id,
                title: s.title,
                expected: s.expected,
                required: !!s.required,
                status: s.status || '',
                comment: s.comment || '',
                evidence: s.evidence || ''
            }))
        };
        if (els.tplName) els.tplName.textContent = run.name || '—';
        els.module.value = run.module || '';
        els.moduleVersion.value = run.module_version || '';
        els.pbxVersion.value = run.pbx_version || '';
        els.tester.value = run.tester || '';
        els.docbeeUrl.value = run.docbee_url || '';
        renderSteps(template.steps);
        recomputeGroupStyles();
        if (els.statusTag) els.statusTag.textContent = 'Lauf geladen';
        updateAutosave();
    } catch (err) {
        alert('Ungültiges JSON: ' + err.message);
    }
});

// === Bindings absichern + Busy-Fix + Mini-CSS ===
(function ensureBindings() {
    // ===== Funktion: bind =====
    // Zweck: siehe Inline-Kommentare im Funktionskörper.
    function bind() {
        const b = document.getElementById('btnPushDocBee');
        if (b) {
            b.removeAttribute('disabled');
            b.classList.remove('is-busy');
            b.style.pointerEvents = 'auto';
            b.addEventListener('click', postToDocBee, {
                once: false
            });
        }
        const p = document.getElementById('btnPrintPDF');
        const btn = document.getElementById('btnExportAll');
        if (btn) {
            btn.addEventListener('click', e => {
                e.preventDefault();
                exportAll().catch(err => alert('Export error: ' + err.message));
            });
            console.log('Export-Button gebunden');
        } else {
            alert('Export-Button nicht gefunden (id=btnExportAll).');
        }
    }
    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', bind);
    else bind();
    window.addEventListener('pageshow', () => {
        const b = document.getElementById('btnPushDocBee');
        if (b) {
            b.removeAttribute('disabled');
            b.classList.remove('is-busy');
            b.style.pointerEvents = 'auto';
        }
    });
})();
(function injectAuxCss() {
    const s = document.createElement('style');
    s.textContent = `
    .is-busy{opacity:.6;pointer-events:none}
    .docbee-hint{margin-top:8px;font-size:14px}
    .docbee-hint a{text-decoration:underline}
  `;
    document.head.appendChild(s);
})();

// ===================== Ende app.js =====================