(function () {
  "use strict";

  var config = window.ObyteQaTool || {};

  function onReady(callback) {
    if (document.readyState === "loading") {
      document.addEventListener("DOMContentLoaded", callback);
    } else {
      callback();
    }
  }

  onReady(function () {
    document.querySelectorAll("[data-obyte-qa-tool]").forEach(function (root) {
      if (root.dataset.oqtReady === "1") {
        return;
      }
      root.dataset.oqtReady = "1";
      new QaTool(root);
    });
  });

  function QaTool(root) {
    this.root = root;
    this.template = null;
    this.dragIndex = -1;
    this.storageKey = "obyteQaToolState:" + window.location.pathname;
    this.els = this.collectElements();
    this.init();
  }

  QaTool.prototype.collectElements = function () {
    var root = this.root;
    var field = function (name) {
      return root.querySelector('[data-field="' + name + '"]');
    };
    var action = function (name) {
      return root.querySelector('[data-action="' + name + '"]');
    };

    return {
      tplName: field("tplName"),
      stepSummary: field("stepSummary"),
      statusTag: field("statusTag"),
      gitlabTplSelect: field("gitlabTplSelect"),
      gitlabTplStatus: field("gitlabTplStatus"),
      docbeeTokenStatus: field("docbeeTokenStatus"),
      templateFile: field("templateFile"),
      module: field("module"),
      moduleVersion: field("moduleVersion"),
      olmNummer: field("olmNummer"),
      pbxVersion: field("pbxVersion"),
      tester: field("tester"),
      docbeeUrl: field("docbeeUrl"),
      stepsTable: field("stepsTable"),
      stepsTableBody: field("stepsTableBody"),
      loadJson: field("loadJson"),
      message: field("message"),
      reloadGitlab: action("reloadGitlab"),
      pickTemplate: action("pickTemplate"),
      addStep: action("addStep"),
      addGroup: action("addGroup"),
      saveJson: action("saveJson"),
      pickRun: action("pickRun"),
      exportAll: action("exportAll"),
      exportMd: action("exportMd"),
      exportCsv: action("exportCsv"),
      printPdf: action("printPdf"),
      exportTemplateYaml: action("exportTemplateYaml"),
      pushDocbee: action("pushDocbee")
    };
  };

  QaTool.prototype.init = function () {
    var self = this;

    if (this.els.tester) {
      this.els.tester.value = config.tester || "";
    }
    if (this.els.pbxVersion && config.defaultPbxVersion) {
      this.els.pbxVersion.value = config.defaultPbxVersion;
    }

    this.restoreAutosave();
    this.bindMetaAutosave();
    this.bindControls();
    this.updateServiceBadges();
    this.updateRunSummary();

    if (config.gitlabEnabled) {
      this.populateGitlabDropdown();
    }
  };

  QaTool.prototype.bindMetaAutosave = function () {
    var self = this;
    ["input", "change"].forEach(function (eventName) {
      [self.els.module, self.els.moduleVersion, self.els.olmNummer, self.els.pbxVersion, self.els.docbeeUrl].forEach(function (el) {
        if (el) {
          el.addEventListener(eventName, function () {
            self.updateAutosave();
          });
        }
      });
    });
  };

  QaTool.prototype.bindControls = function () {
    var self = this;

    bind(this.els.reloadGitlab, "click", function (event) {
      event.preventDefault();
      self.populateGitlabDropdown();
    });

    bind(this.els.pickTemplate, "click", function () {
      if (self.els.templateFile) {
        self.els.templateFile.click();
      }
    });

    bind(this.els.templateFile, "change", function (event) {
      self.loadLocalTemplate(event);
    });

    bind(this.els.gitlabTplSelect, "change", function (event) {
      self.loadGitlabTemplate(event.target.value);
    });

    bind(this.els.addStep, "click", function () {
      self.addStep();
    });

    bind(this.els.addGroup, "click", function () {
      self.addGroup();
    });

    bind(this.els.saveJson, "click", function () {
      self.saveJson();
    });

    bind(this.els.pickRun, "click", function () {
      if (self.els.loadJson) {
        self.els.loadJson.click();
      }
    });

    bind(this.els.loadJson, "change", function (event) {
      self.loadRunJson(event);
    });

    bind(this.els.exportAll, "click", function () {
      self.exportAll();
    });

    bind(this.els.exportMd, "click", function () {
      self.exportMd();
    });

    bind(this.els.exportCsv, "click", function () {
      self.exportCsv();
    });

    bind(this.els.printPdf, "click", function () {
      self.printPdf();
    });

    bind(this.els.exportTemplateYaml, "click", function () {
      self.exportTemplateYaml();
    });

    bind(this.els.pushDocbee, "click", function () {
      self.postToDocbee();
    });

    if (this.els.stepsTableBody) {
      this.els.stepsTableBody.addEventListener("click", function (event) {
        self.handleRowClick(event);
      });
      this.els.stepsTableBody.addEventListener("input", function () {
        self.captureEditsIntoTemplate();
        self.updateRunSummary();
      });
      this.els.stepsTableBody.addEventListener("change", function (event) {
        if (event.target && event.target.matches("select.oqt-status")) {
          self.updateStatusClass(event.target);
          self.recomputeGroupStyles();
        }
        self.captureEditsIntoTemplate();
        self.updateRunSummary();
      });
      this.els.stepsTableBody.addEventListener("dragstart", function (event) {
        self.handleDragStart(event);
      });
      this.els.stepsTableBody.addEventListener("dragend", function (event) {
        self.handleDragEnd(event);
      });
      this.els.stepsTableBody.addEventListener("dragover", function (event) {
        self.handleDragOver(event);
      });
      this.els.stepsTableBody.addEventListener("drop", function (event) {
        self.handleDrop(event);
      });
    }
  };

  QaTool.prototype.api = async function (path, options) {
    options = options || {};
    var response = await fetch((config.restUrl || "") + path, {
      method: options.method || "GET",
      credentials: "same-origin",
      headers: Object.assign({
        "Accept": "application/json",
        "X-WP-Nonce": config.nonce || ""
      }, options.headers || {}),
      body: options.body || null
    });

    var text = await response.text();
    var data = {};
    if (text) {
      try {
        data = JSON.parse(text);
      } catch (error) {
        data = { message: text };
      }
    }

    if (!response.ok) {
      throw new Error(data.message || ("Request failed with HTTP " + response.status));
    }

    return data;
  };

  QaTool.prototype.populateGitlabDropdown = async function () {
    var select = this.els.gitlabTplSelect;
    var tag = this.els.gitlabTplStatus;

    if (!select) {
      return;
    }

      select.innerHTML = '<option value="">GitLab-Templates laden</option>';

    if (!config.gitlabEnabled) {
      select.disabled = true;
      this.setTag(tag, "GitLab: deaktiviert", "warn");
      return;
    }

    try {
      select.disabled = true;
      this.setTag(tag, "GitLab: lade Liste", "");
      var data = await this.api("templates");
      var templates = data.templates || [];

      templates.forEach(function (item) {
        var option = document.createElement("option");
        option.value = item.path;
        option.textContent = item.name;
        select.appendChild(option);
      });

      this.setTag(tag, templates.length ? ("GitLab: " + templates.length + " Vorlage(n)") : "GitLab: keine YAMLs", templates.length ? "ok" : "warn");
    } catch (error) {
      this.setTag(tag, "GitLab: Fehler", "bad");
      this.showMessage("GitLab-Liste konnte nicht geladen werden: " + error.message, "bad");
    } finally {
      select.disabled = false;
    }
  };

  QaTool.prototype.loadGitlabTemplate = async function (path) {
    if (!path) {
      return;
    }

    try {
      this.setTag(this.els.gitlabTplStatus, "GitLab: lade Datei", "");
      var data = await this.api("template?path=" + encodeURIComponent(path));
      this.loadTemplateText(data.content || "", path.split("/").pop());
      this.setTag(this.els.gitlabTplStatus, "GitLab: geladen", "ok");
    } catch (error) {
      this.setTag(this.els.gitlabTplStatus, "GitLab: Fehler", "bad");
      this.showMessage("Vorlage aus GitLab konnte nicht geladen werden: " + error.message, "bad");
    }
  };

  QaTool.prototype.loadLocalTemplate = async function (event) {
    var file = event.target.files && event.target.files[0];
    if (!file) {
      return;
    }

    try {
      var text = await file.text();
      this.loadTemplateText(text, file.name);
      this.showMessage("Lokale Vorlage geladen.", "ok");
    } catch (error) {
      this.showMessage("Vorlage konnte nicht geladen werden: " + error.message, "bad");
    } finally {
      event.target.value = "";
    }
  };

  QaTool.prototype.loadTemplateText = function (text, fallbackName) {
    var parsed = parseTemplateText(text);
    var normalized = normalizeTemplate(parsed, fallbackName || "Template");
    this.template = normalized;

    if (this.els.tplName) {
      this.els.tplName.textContent = normalized.name || fallbackName || "Template";
    }
    if (this.els.module) {
      this.els.module.value = normalized.module || "";
    }
    if (this.els.moduleVersion) {
      this.els.moduleVersion.value = normalized.module_version || "";
    }
    if (this.els.olmNummer) {
      this.els.olmNummer.value = normalized.olm_nummer || "";
    }
    if (this.els.pbxVersion) {
      this.els.pbxVersion.value = normalized.pbx_version || config.defaultPbxVersion || "";
    }

    this.renderSteps();
    this.setTag(this.els.statusTag, "Vorlage geladen", "ok");
    this.updateAutosave();
  };

  QaTool.prototype.renderSteps = function () {
    var body = this.els.stepsTableBody;
    if (!body || !this.template) {
      return;
    }

    body.innerHTML = "";
    var groupCollapsed = false;
    var self = this;

    (this.template.steps || []).forEach(function (step) {
      var kind = step.kind || step.type || "step";
      if (kind === "group") {
        var groupRow = document.createElement("tr");
        groupRow.className = "oqt-group-row";
        groupRow.dataset.kind = "group";
        groupRow.draggable = false;
        if (step.collapsed) {
          groupRow.dataset.collapsed = "1";
        }

        groupRow.innerHTML =
          '<td colspan="4">' +
            '<div class="oqt-group-head">' +
              '<div class="oqt-group-main">' +
                '<span class="oqt-group-label">Gruppe</span>' +
                '<input class="oqt-group-title" type="text" value="' + escAttr(step.title || "") + '" placeholder="Gruppen-Titel">' +
              '</div>' +
              '<div class="oqt-group-actions">' +
                '<div class="oqt-status-set" aria-label="Gruppenstatus setzen">' +
                  '<button type="button" class="oqt-status-btn is-pass" data-row-action="group-status" data-status="pass">PASS</button>' +
                  '<button type="button" class="oqt-status-btn is-fail" data-row-action="group-status" data-status="fail">FAIL</button>' +
                  '<button type="button" class="oqt-status-btn is-skip" data-row-action="group-status" data-status="skip">SKIP</button>' +
                  '<button type="button" class="oqt-status-btn is-blocked" data-row-action="group-status" data-status="blocked">BLOCK</button>' +
                '</div>' +
                '<div class="oqt-row-tools">' +
                '<button type="button" class="oqt-icon-btn" data-row-action="toggle-group" title="' + (step.collapsed ? "Gruppe öffnen" : "Gruppe schließen") + '" aria-label="' + (step.collapsed ? "Gruppe öffnen" : "Gruppe schließen") + '">' +
                  '<span class="dashicons ' + (step.collapsed ? "dashicons-arrow-down-alt2" : "dashicons-arrow-up-alt2") + '" aria-hidden="true"></span>' +
                '</button>' +
                '<button type="button" class="oqt-icon-btn" data-row-action="delete-group" title="Gruppe löschen" aria-label="Gruppe löschen">' +
                  '<span class="dashicons dashicons-trash" aria-hidden="true"></span>' +
                '</button>' +
                '<span class="oqt-drag-handle" draggable="true" title="Ziehen zum Verschieben" aria-label="Ziehen zum Verschieben"><span class="dashicons dashicons-menu" aria-hidden="true"></span></span>' +
                '</div>' +
              '</div>' +
            '</div>' +
          '</td>';

        body.appendChild(groupRow);
        groupCollapsed = !!step.collapsed;
        return;
      }

      var row = document.createElement("tr");
      row.dataset.kind = "step";
      row.draggable = false;
      if (groupCollapsed) {
        row.dataset.hidden = "1";
      }

      row.innerHTML =
        '<td>' +
          '<div class="oqt-step-head">' +
            '<div class="oqt-step-id-wrap">' +
              '<span class="oqt-mini-label">ID</span>' +
              '<input class="oqt-step-id" type="text" value="' + escAttr(step.id || "") + '" aria-label="Step ID">' +
            '</div>' +
            '<div class="oqt-step-title-wrap">' +
              '<span class="oqt-mini-label">Titel</span>' +
              '<textarea class="oqt-title" placeholder="Titel">' + escHtml(step.title || "") + '</textarea>' +
            '</div>' +
            '<div class="oqt-row-tools oqt-step-tools">' +
              (step.required ? '<span class="oqt-pin">Pflicht</span>' : '<span class="oqt-pin oqt-pin--off">Optional</span>') +
              '<button type="button" class="oqt-icon-btn" data-row-action="delete-step" title="Step löschen" aria-label="Step löschen">' +
                '<span class="dashicons dashicons-trash" aria-hidden="true"></span>' +
              '</button>' +
              '<span class="oqt-drag-handle" draggable="true" title="Ziehen zum Verschieben" aria-label="Ziehen zum Verschieben"><span class="dashicons dashicons-menu" aria-hidden="true"></span></span>' +
            '</div>' +
          '</div>' +
          '<label class="oqt-required"><input type="checkbox" class="oqt-step-required" ' + (step.required ? "checked" : "") + '> Pflichtschritt</label>' +
        '</td>' +
        '<td><textarea class="oqt-expected" placeholder="Erwartetes Verhalten">' + escHtml(step.expected || "") + '</textarea></td>' +
        '<td>' +
          '<select class="oqt-status">' +
            '<option value="" ' + (!step.status ? "selected" : "") + '></option>' +
            '<option value="pass" ' + (step.status === "pass" ? "selected" : "") + '>pass</option>' +
            '<option value="fail" ' + (step.status === "fail" ? "selected" : "") + '>fail</option>' +
            '<option value="skip" ' + (step.status === "skip" ? "selected" : "") + '>skip</option>' +
            '<option value="blocked" ' + (step.status === "blocked" ? "selected" : "") + '>blocked</option>' +
          '</select>' +
        '</td>' +
        '<td>' +
          '<textarea class="oqt-comment" placeholder="Kommentar">' + escHtml(step.comment || "") + '</textarea>' +
          '<input class="oqt-evidence" type="url" placeholder="Evidenz-URL" value="' + escAttr(step.evidence || "") + '">' +
        '</td>';

      body.appendChild(row);
      self.updateStatusClass(row.querySelector("select.oqt-status"));
    });

    this.recomputeGroupStyles();
    this.updateRunSummary();
  };

  QaTool.prototype.captureEditsIntoTemplate = function () {
    if (!this.template || !this.els.stepsTableBody) {
      return;
    }

    var rows = Array.prototype.slice.call(this.els.stepsTableBody.querySelectorAll("tr"));
    this.template.steps = rows.map(function (row, index) {
      var kind = row.dataset.kind || "step";
      if (kind === "group") {
        return {
          kind: "group",
          title: valueOf(row, ".oqt-group-title"),
          collapsed: row.dataset.collapsed === "1"
        };
      }

      return {
        kind: "step",
        id: valueOf(row, ".oqt-step-id") || ("step-" + String(index + 1).padStart(3, "0")),
        title: valueOf(row, ".oqt-title"),
        expected: valueOf(row, ".oqt-expected"),
        required: !!(row.querySelector(".oqt-step-required") && row.querySelector(".oqt-step-required").checked),
        status: valueOf(row, ".oqt-status"),
        comment: valueOf(row, ".oqt-comment"),
        evidence: valueOf(row, ".oqt-evidence")
      };
    });
  };

  QaTool.prototype.ensureTemplate = function () {
    if (!this.template) {
      this.template = {
        name: "Neues Template",
        module: "",
        module_version: "",
        olm_nummer: "",
        pbx_version: config.defaultPbxVersion || "",
        steps: []
      };
      if (this.els.tplName) {
        this.els.tplName.textContent = this.template.name;
      }
    }
  };

  QaTool.prototype.addStep = function () {
    this.ensureTemplate();
    this.captureEditsIntoTemplate();
    this.template.steps.push({
      kind: "step",
      id: "",
      title: "",
      expected: "",
      required: true,
      status: "",
      comment: "",
      evidence: ""
    });
    this.renumberSteps();
    this.renderSteps();
    this.setTag(this.els.statusTag, "Step hinzugefügt", "ok");
  };

  QaTool.prototype.addGroup = function () {
    this.ensureTemplate();
    this.captureEditsIntoTemplate();
    this.template.steps.push({
      kind: "group",
      title: "Neue Gruppe",
      collapsed: false
    });
    this.renderSteps();
    this.setTag(this.els.statusTag, "Gruppe hinzugefügt", "ok");
  };

  QaTool.prototype.handleRowClick = function (event) {
    var button = event.target.closest("[data-row-action]");
    if (!button) {
      return;
    }

    event.preventDefault();
    this.captureEditsIntoTemplate();

    var row = button.closest("tr");
    var index = this.rowIndex(row);
    if (index < 0 || !this.template || !this.template.steps[index]) {
      return;
    }

    var action = button.dataset.rowAction;
    var item = this.template.steps[index];

    if (action === "toggle-group" && item.kind === "group") {
      item.collapsed = !item.collapsed;
      this.renderSteps();
      return;
    }

    if (action === "group-status" && item.kind === "group") {
      this.applyGroupStatus(index, button.dataset.status || "");
      return;
    }

    if (action === "delete-group" && item.kind === "group") {
      var count = this.countStepsUntilNextGroup(index);
      if (count > 0 && !window.confirm("Diese Gruppe enthält " + count + " Schritt(e). Gruppe wirklich löschen? Die Schritte bleiben erhalten.")) {
        return;
      }
      this.template.steps.splice(index, 1);
      this.renderSteps();
      return;
    }

    if (action === "delete-step") {
      this.template.steps.splice(index, 1);
      this.renumberSteps();
      this.renderSteps();
    }
  };

  QaTool.prototype.applyGroupStatus = function (groupIndex, status) {
    if (["pass", "fail", "skip", "blocked"].indexOf(status) === -1) {
      return;
    }

    var end = this.template.steps.length;
    for (var i = groupIndex + 1; i < this.template.steps.length; i++) {
      var kind = this.template.steps[i].kind || this.template.steps[i].type || "step";
      if (kind === "group") {
        end = i;
        break;
      }
    }

    var willOverwrite = this.template.steps.slice(groupIndex + 1, end).some(function (step) {
      return (step.kind || "step") === "step" && step.status && step.status !== status;
    });

    if (willOverwrite && !window.confirm("Einige Schritte haben bereits einen anderen Status. Alle auf " + status.toUpperCase() + " setzen?")) {
      return;
    }

    for (var j = groupIndex + 1; j < end; j++) {
      if ((this.template.steps[j].kind || "step") === "step") {
        this.template.steps[j].status = status;
      }
    }

    this.renderSteps();
  };

  QaTool.prototype.countStepsUntilNextGroup = function (groupIndex) {
    var count = 0;
    for (var i = groupIndex + 1; i < this.template.steps.length; i++) {
      var kind = this.template.steps[i].kind || this.template.steps[i].type || "step";
      if (kind === "group") {
        break;
      }
      if (kind === "step") {
        count++;
      }
    }
    return count;
  };

  QaTool.prototype.rowIndex = function (row) {
    if (!row || !this.els.stepsTableBody) {
      return -1;
    }
    return Array.prototype.indexOf.call(this.els.stepsTableBody.children, row);
  };

  QaTool.prototype.handleDragStart = function (event) {
    if (!event.target.closest(".oqt-drag-handle")) {
      event.preventDefault();
      return;
    }

    var row = event.target.closest("tr");
    var index = this.rowIndex(row);
    if (index < 0) {
      return;
    }

    this.dragIndex = index;
    row.classList.add("oqt-dragging");
    if (event.dataTransfer) {
      event.dataTransfer.effectAllowed = "move";
      event.dataTransfer.setData("text/plain", String(index));
    }
  };

  QaTool.prototype.handleDragEnd = function (event) {
    var row = event.target.closest("tr");
    if (row) {
      row.classList.remove("oqt-dragging");
    }
    this.clearDragMarkers();
    this.dragIndex = -1;
  };

  QaTool.prototype.handleDragOver = function (event) {
    event.preventDefault();
    var row = event.target.closest("tr");
    if (!row) {
      return;
    }

    this.clearDragMarkers();
    row.classList.add("oqt-drag-over");
    var rect = row.getBoundingClientRect();
    row.dataset.dropPos = (event.clientY - rect.top) < rect.height / 2 ? "before" : "after";
  };

  QaTool.prototype.handleDrop = function (event) {
    event.preventDefault();
    var row = event.target.closest("tr");
    if (!row || !this.template) {
      return;
    }

    this.captureEditsIntoTemplate();
    var from = this.dragIndex >= 0 ? this.dragIndex : parseInt(event.dataTransfer && event.dataTransfer.getData("text/plain"), 10);
    var toBase = this.rowIndex(row);
    if (!isFinite(from) || from < 0 || toBase < 0) {
      return;
    }

    var to = toBase + (row.dataset.dropPos === "after" ? 1 : 0);
    if (to === from || to - 1 === from) {
      this.renderSteps();
      return;
    }

    var item = this.template.steps.splice(from, 1)[0];
    if (to > from) {
      to--;
    }
    this.template.steps.splice(to, 0, item);
    this.renumberSteps();
    this.renderSteps();
  };

  QaTool.prototype.clearDragMarkers = function () {
    if (!this.els.stepsTableBody) {
      return;
    }
    this.els.stepsTableBody.querySelectorAll("tr").forEach(function (row) {
      row.classList.remove("oqt-drag-over");
      delete row.dataset.dropPos;
    });
  };

  QaTool.prototype.updateStatusClass = function (select) {
    if (!select) {
      return;
    }
    var row = select.closest("tr");
    ["oqt-status-pass", "oqt-status-fail", "oqt-status-skip", "oqt-status-blocked"].forEach(function (className) {
      select.classList.remove(className);
    });
    ["oqt-row-pass", "oqt-row-fail", "oqt-row-skip", "oqt-row-blocked"].forEach(function (className) {
      if (row) {
        row.classList.remove(className);
      }
    });

    if (!select.value) {
      return;
    }

    select.classList.add("oqt-status-" + select.value);
    if (row) {
      row.classList.add("oqt-row-" + select.value);
    }
  };

  QaTool.prototype.recomputeGroupStyles = function () {
    if (!this.els.stepsTableBody) {
      return;
    }

    var current = null;
    var groups = [];
    this.els.stepsTableBody.querySelectorAll("tr").forEach(function (row) {
      if ((row.dataset.kind || "step") === "group") {
        current = { row: row, steps: [] };
        groups.push(current);
      } else if (current) {
        current.steps.push(row);
      }
    });

    groups.forEach(function (group) {
      group.row.classList.remove("is-ok", "is-bad");
      var statuses = group.steps.map(function (row) {
        var select = row.querySelector("select.oqt-status");
        return select ? select.value : "";
      });
      if (!statuses.length) {
        return;
      }
      if (statuses.every(function (status) { return status === "pass"; })) {
        group.row.classList.add("is-ok");
      } else if (statuses.some(function (status) { return status === "fail" || status === "blocked"; })) {
        group.row.classList.add("is-bad");
      }
    });
  };

  QaTool.prototype.updateRunSummary = function () {
    if (!this.els.stepSummary) {
      return;
    }

    var counts = countStatuses(this.template ? this.template.steps : []);
    if (!counts.total) {
      this.setTag(this.els.stepSummary, "0 Steps", "");
      return;
    }

    var text = counts.total + " Step" + (counts.total === 1 ? "" : "s") +
      " | " + counts.pass + " PASS" +
      " | " + counts.fail + " FAIL" +
      " | " + counts.skip + " SKIP" +
      " | " + counts.blocked + " BLOCK";
    if (counts.missingRequired) {
      text += " | " + counts.missingRequired + " offen";
    }
    this.setTag(this.els.stepSummary, text, counts.missingRequired ? "warn" : (counts.total ? "ok" : ""));
  };

  QaTool.prototype.renumberSteps = function () {
    if (!this.template || !Array.isArray(this.template.steps)) {
      return;
    }

    var number = 1;
    this.template.steps.forEach(function (step) {
      var kind = step.kind || step.type || "step";
      if (kind !== "step") {
        return;
      }
      step.id = "step-" + String(number).padStart(3, "0");
      number++;
    });
  };

  QaTool.prototype.collectRun = function () {
    if (!this.template) {
      this.showMessage("Bitte zuerst eine Vorlage laden oder Schritte anlegen.", "bad");
      return null;
    }

    this.captureEditsIntoTemplate();
    this.renumberSteps();
    return {
      name: this.template.name || "",
      module: value(this.els.module),
      module_version: value(this.els.moduleVersion),
      olm_nummer: value(this.els.olmNummer),
      pbx_version: value(this.els.pbxVersion),
      tester: value(this.els.tester),
      docbee_url: value(this.els.docbeeUrl),
      ts: new Date().toISOString(),
      steps: clone(this.template.steps || [])
    };
  };

  QaTool.prototype.collectTemplateFromDom = function () {
    this.ensureTemplate();
    this.captureEditsIntoTemplate();
    this.renumberSteps();

    return {
      name: this.template.name || "QA Template",
      module: value(this.els.module),
      module_version: value(this.els.moduleVersion),
      olm_nummer: value(this.els.olmNummer),
      pbx_version: value(this.els.pbxVersion),
      steps: (this.template.steps || []).map(function (step) {
        var kind = step.kind || step.type || "step";
        if (kind === "group") {
          return { type: "group", title: step.title || "" };
        }
        return {
          type: "step",
          id: step.id || "",
          title: step.title || "",
          expected: step.expected || "",
          required: !!step.required
        };
      })
    };
  };

  QaTool.prototype.checkRequired = function (run) {
    var missing = (run.steps || []).filter(function (step) {
      return (step.kind || step.type || "step") === "step" && step.required && !step.status;
    });

    if (!missing.length) {
      return true;
    }

    var message = "Folgende Pflichtschritte haben keinen Status:\n" + missing.map(function (step) {
      return (step.id || "") + " - " + (step.title || "");
    }).join("\n");
    window.alert(message);
    this.showMessage("Pflichtschritte ohne Status vorhanden.", "bad");
    return false;
  };

  QaTool.prototype.saveJson = function () {
    var run = this.collectRun();
    if (!run || !this.checkRequired(run)) {
      return;
    }
    this.renumberSteps();
    download(JSON.stringify(run, null, 2), runBaseName("qa-run", run) + ".json", "application/json");
  };

  QaTool.prototype.loadRunJson = async function (event) {
    var file = event.target.files && event.target.files[0];
    if (!file) {
      return;
    }

    try {
      var text = await file.text();
      var run = parseTemplateText(text);
      var isRun = !!(run.tester || run.docbee_url || (run.steps || []).some(function (step) {
        return step && (step.status || step.comment || step.evidence);
      }));
      if (!run || !Array.isArray(run.steps)) {
        throw new Error("Datei enthält keine steps.");
      }

      this.template = normalizeTemplate({
        name: run.name || file.name,
        module: run.module || "",
        module_version: run.module_version || "",
        olm_nummer: run.olm_nummer || "",
        pbx_version: run.pbx_version || "",
        steps: run.steps
      }, file.name);

      if (this.els.tplName) {
        this.els.tplName.textContent = run.name || file.name;
      }
      if (this.els.module) {
        this.els.module.value = run.module || "";
      }
      if (this.els.moduleVersion) {
        this.els.moduleVersion.value = run.module_version || "";
      }
      if (this.els.olmNummer) {
        this.els.olmNummer.value = run.olm_nummer || "";
      }
      if (this.els.pbxVersion) {
        this.els.pbxVersion.value = run.pbx_version || "";
      }
      if (this.els.tester) {
        this.els.tester.value = isRun ? (run.tester || config.tester || "") : (config.tester || "");
      }
      if (this.els.docbeeUrl) {
        this.els.docbeeUrl.value = isRun ? (run.docbee_url || "") : "";
      }

      this.renderSteps();
      this.setTag(this.els.statusTag, isRun ? "Lauf geladen" : "Template geladen", "ok");
      this.updateAutosave();
    } catch (error) {
      this.showMessage("Datei konnte nicht geladen werden: " + error.message, "bad");
    } finally {
      event.target.value = "";
    }
  };

  QaTool.prototype.exportMd = function () {
    var run = this.collectRun();
    if (!run || !this.checkRequired(run)) {
      return;
    }
    this.renumberSteps();

    var md = "# Testprotokoll\n\n" +
      "- Modul: " + (run.module || "") + "\n" +
      "- Modul-Version: " + (run.module_version || "") + "\n" +
      "- OLM-Nummer: " + (run.olm_nummer || "") + "\n" +
      "- PBX-Version: " + (run.pbx_version || "") + "\n" +
      "- DocBee: " + (run.docbee_url || "") + "\n" +
      "- Tester: " + (run.tester || "") + "\n" +
      "- Datum: " + new Date(run.ts).toLocaleString("de-DE") + "\n\n" +
      "| Schritt | Erwartung | Status | Kommentar | Evidenz |\n" +
      "|---|---|---|---|---|\n";

    run.steps.forEach(function (step) {
      if ((step.kind || step.type || "step") === "group") {
        md += "| **-- " + mdCell(step.title) + " --** | | | | |\n";
        return;
      }
      md += "| **" + mdCell(step.id) + " - " + mdCell(step.title) + (step.required ? " [required]" : "") + "** | " +
        mdCell(step.expected) + " | " + mdCell(step.status) + " | " + mdCell(step.comment) + " | " + mdCell(step.evidence) + " |\n";
    });

    download(md, runBaseName("qa-report", run) + ".md", "text/markdown");
  };

  QaTool.prototype.exportCsv = function () {
    var run = this.collectRun();
    if (!run || !this.checkRequired(run)) {
      return;
    }
    this.renumberSteps();

    var meta = [
      ["module", run.module || ""],
      ["module_version", run.module_version || ""],
      ["olm_nummer", run.olm_nummer || ""],
      ["pbx_version", run.pbx_version || ""],
      ["docbee_url", run.docbee_url || ""],
      ["tester", run.tester || ""],
      ["date", new Date(run.ts).toLocaleString("de-DE")]
    ].map(function (row) {
      return row[0] + ";" + csvCell(row[1]);
    }).join("\n");

    var rows = [["id", "title", "expected", "required", "status", "comment", "evidence"]];
    run.steps.forEach(function (step) {
      if ((step.kind || step.type || "step") === "group") {
        rows.push(["", "### Gruppe: " + (step.title || ""), "", "", "", "", ""]);
        return;
      }
      rows.push([step.id, step.title, step.expected, step.required ? "true" : "false", step.status, step.comment, step.evidence]);
    });

    var table = rows.map(function (row) {
      return row.map(csvCell).join(";");
    }).join("\n");

    download(meta + "\n\n" + table, runBaseName("qa-report", run) + ".csv", "text/csv");
  };

  QaTool.prototype.ensureJsPdf = async function () {
    if (window.jspdf && window.jspdf.jsPDF) {
      return true;
    }

    var urls = config.jsPdfUrls || [];
    for (var i = 0; i < urls.length; i++) {
      try {
        await loadScript(urls[i]);
      } catch (error) {
        // Try next CDN.
      }
      if (window.jspdf && window.jspdf.jsPDF) {
        break;
      }
    }

    if (!(window.jspdf && window.jspdf.jsPDF)) {
      return false;
    }

    try {
      var test = new window.jspdf.jsPDF();
      if (typeof test.autoTable !== "function") {
        var tableUrls = config.autoTableUrls || [];
        for (var j = 0; j < tableUrls.length; j++) {
          try {
            await loadScript(tableUrls[j]);
          } catch (error2) {
            // PDF export still works without AutoTable.
          }
          test = new window.jspdf.jsPDF();
          if (typeof test.autoTable === "function") {
            break;
          }
        }
      }
    } catch (error3) {
      // Basic jsPDF is enough for a fallback PDF.
    }

    return true;
  };

  QaTool.prototype.generatePdfBlob = async function (run) {
    var ready = await this.ensureJsPdf();
    if (!ready) {
      throw new Error("PDF-Bibliothek konnte nicht geladen werden.");
    }

    var jsPDF = window.jspdf.jsPDF;
    var doc = new jsPDF({
      orientation: "landscape",
      unit: "mm",
      format: "a4",
      compress: true
    });

    var pageW = doc.internal.pageSize.getWidth();
    var pageH = doc.internal.pageSize.getHeight();
    var marginL = 14;
    var marginR = 14;
    var xRight = pageW - marginR;
    var footerY = pageH - 13;
    var muted = [102, 102, 102];
    var headFill = [232, 247, 253];
    var ok = [24, 143, 92];
    var fail = [196, 59, 59];
    var skip = [103, 113, 122];
    var blocked = [245, 156, 0];
    var logoDataUrl = null;

    try {
      logoDataUrl = await imageToDataUrl(config.printLogoUrl || config.logoUrl || "");
    } catch (error) {
      logoDataUrl = null;
    }

    doc.setFont("helvetica", "bold");
    doc.setFontSize(18);
    doc.setTextColor(52, 53, 55);
    doc.text("QA Report", marginL, 16);
    if (logoDataUrl) {
      try {
        var props = doc.getImageProperties ? doc.getImageProperties(logoDataUrl) : null;
        var ratio = props && props.width ? props.height / props.width : 0.38;
        var logoW = 32;
        var logoH = logoW * ratio;
        if (logoH > 13) {
          logoH = 13;
          logoW = logoH / ratio;
        }
        doc.addImage(logoDataUrl, "PNG", xRight - logoW, 6.5, logoW, logoH, undefined, "FAST");
      } catch (error2) {
        // Logo is decorative; keep the PDF export working without it.
      }
    }
    doc.setDrawColor(0, 167, 230);
    doc.setLineWidth(0.45);
    doc.line(marginL, 18, xRight, 18);

    var meta = [
      ["Modul", sanitizePdfText(run.module)],
      ["Modul-Version", sanitizePdfText(run.module_version)],
      ["OLM-Nummer", sanitizePdfText(run.olm_nummer || "-")],
      ["PBX-Version", sanitizePdfText(run.pbx_version)],
      ["Tester", sanitizePdfText(run.tester)],
      ["DocBee", sanitizePdfText(run.docbee_url || "-")]
    ];
    var y = 25;
    doc.setFontSize(10);
    meta.forEach(function (row) {
      doc.setFont("helvetica", "bold");
      doc.setTextColor(muted[0], muted[1], muted[2]);
      doc.text(row[0] + ":", marginL, y);
      doc.setFont("helvetica", "normal");
      doc.setTextColor(0, 0, 0);
      doc.text(row[1] || "-", 50, y);
      y += 6;
    });

    var rows = [];
    var currentGroup = "-";
    var index = 0;
    (run.steps || []).forEach(function (step) {
      if ((step.kind || step.type || "step") === "group") {
        currentGroup = sanitizePdfText(step.title || "-");
        return;
      }
      index++;
      rows.push({
        nr: index,
        group: currentGroup,
        id: sanitizePdfText(step.id),
        title: sanitizePdfText(step.title) + (step.required ? " [required]" : ""),
        expected: sanitizePdfText(step.expected),
        status: sanitizePdfText((step.status || "").toLowerCase()),
        comment: sanitizePdfText(step.comment)
      });
    });

    if (typeof doc.autoTable === "function") {
      doc.autoTable({
        startY: Math.max(y + 4, 25),
        tableWidth: pageW - (marginL + marginR),
        head: [["#", "Gruppe", "Step-ID", "Titel", "Erwartet", "Status", "Kommentar"]],
        body: rows.map(function (row) {
          return [row.nr, row.group, row.id, row.title, row.expected, row.status, row.comment];
        }),
        styles: {
          font: "helvetica",
          fontSize: 9,
          cellPadding: 2,
          overflow: "linebreak",
          valign: "top"
        },
        headStyles: {
          fillColor: headFill,
          textColor: [52, 53, 55],
          halign: "left"
        },
        columnStyles: {
          0: { halign: "right", cellWidth: 8 },
          1: { cellWidth: 36 },
          2: { cellWidth: 24 },
          3: { cellWidth: 62 },
          4: { cellWidth: 76, font: "courier", fillColor: [250, 250, 250] },
          5: { cellWidth: 20 },
          6: { cellWidth: 36 }
        },
        didParseCell: function (data) {
          if (data.section === "body" && data.column.index === 5) {
            var value = String(data.cell.raw || "").toLowerCase();
            if (value === "pass") {
              data.cell.styles.textColor = ok;
            } else if (value === "fail") {
              data.cell.styles.textColor = fail;
            } else if (value === "skip" || value === "na") {
              data.cell.styles.textColor = skip;
            } else if (value === "blocked") {
              data.cell.styles.textColor = blocked;
            }
          }
        },
        margin: { left: marginL, right: marginR },
        pageBreak: "auto"
      });
    } else {
      doc.setFont("helvetica", "normal");
      doc.setFontSize(9);
      y += 6;
      rows.forEach(function (row) {
        if (y > pageH - 20) {
          doc.addPage();
          y = 16;
        }
        doc.text([row.nr + " " + row.id + " " + row.status, row.group + " | " + row.title, row.expected, row.comment].join(" - "), marginL, y, {
          maxWidth: pageW - marginL - marginR
        });
        y += 8;
      });
    }

    var pageCount = doc.getNumberOfPages();
    var timestamp = new Date().toLocaleString("de-DE");
    for (var p = 1; p <= pageCount; p++) {
      doc.setPage(p);
      doc.setFont("helvetica", "normal");
      doc.setFontSize(8);
      doc.setTextColor(muted[0], muted[1], muted[2]);
      doc.text("Seite " + p + " / " + pageCount, xRight, footerY, { align: "right" });
      doc.text(timestamp, marginL, footerY);
    }

    return doc.output("blob");
  };

  QaTool.prototype.printPdf = async function () {
    var run = this.collectRun();
    if (!run || !this.checkRequired(run)) {
      return;
    }
    this.renumberSteps();

    try {
      var pdfBlob = await this.generatePdfBlob(run);
      download(pdfBlob, runBaseName("qa-report", run) + ".pdf", "application/pdf");
      this.showMessage("PDF wurde erzeugt.", "ok");
      return;
    } catch (error) {
      this.showMessage("PDF konnte nicht erzeugt werden: " + error.message, "bad");
      return;
    }
  };

  QaTool.prototype.exportAll = async function () {
    var run = this.collectRun();
    if (!run || !this.checkRequired(run)) {
      return;
    }

    var button = this.els.exportAll;
    try {
      if (button) {
        button.disabled = true;
        button.classList.add("is-busy");
      }

      this.showMessage("Export läuft: PDF wird erzeugt und WordPress speichert den Lauf...", "");
      var pdfBlob = null;
      try {
        pdfBlob = await this.generatePdfBlob(run);
      } catch (pdfError) {
        this.showMessage("PDF konnte nicht erzeugt werden, Report wird trotzdem gespeichert: " + pdfError.message, "bad");
      }

      var form = new FormData();
      form.append("run", JSON.stringify(run));
      if (pdfBlob) {
        form.append("pdf", pdfBlob, runBaseName("qa-report", run) + ".pdf");
      }

      var data = await this.api("export", {
        method: "POST",
        body: form
      });

      var parts = [];
      if (data.report_id) {
        parts.push("Report-ID: " + data.report_id);
      }
      if (data.pdf_url) {
        parts.push('<a href="' + escAttr(data.pdf_url) + '" target="_blank" rel="noopener">PDF in Mediathek öffnen</a>');
      }
      if (data.docbee && data.docbee.ok) {
        var docbeeLink = data.docbee.url ? ' <a href="' + escAttr(data.docbee.url) + '" target="_blank" rel="noopener">DocBee öffnen</a>' : "";
        parts.push("DocBee gepostet." + docbeeLink);
      } else if (data.docbee && data.docbee.message) {
        parts.push("DocBee Fehler: " + escHtml(data.docbee.message));
      }

      this.showMessage("Export fertig. " + parts.join(" | "), "ok", true);
    } catch (error) {
      this.showMessage("Export fehlgeschlagen: " + error.message, "bad");
    } finally {
      if (button) {
        button.disabled = false;
        button.classList.remove("is-busy");
      }
    }
  };

  QaTool.prototype.exportTemplateYaml = async function () {
    var template = this.collectTemplateFromDom();
    if (!template.module || !template.module_version || !template.pbx_version) {
      this.showMessage("Bitte Modul, Modul-Version und PBX-Version angeben.", "bad");
      return;
    }

    var yaml = dumpTemplateYaml(template);
    var button = this.els.exportTemplateYaml;

    if (!config.gitlabWritesEnabled) {
      download(yaml, runBaseName("qa-template", template) + ".yaml", "text/yaml");
      this.showMessage("GitLab-Schreiben ist deaktiviert. Template wurde lokal gespeichert.", "ok");
      this.renderSteps();
      return;
    }

    try {
      if (button) {
        button.disabled = true;
        button.classList.add("is-busy");
      }
      this.showMessage("GitLab: Template wird geschrieben...", "");
      var data = await this.api("gitlab/template", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ template: template, yaml: yaml })
      });
      this.showMessage("GitLab: Template gespeichert: " + (data.path || ""), "ok");
    } catch (error) {
      this.showMessage("GitLab-Push fehlgeschlagen: " + error.message, "bad");
    } finally {
      if (button) {
        button.disabled = false;
        button.classList.remove("is-busy");
      }
      this.renderSteps();
    }
  };

  QaTool.prototype.postToDocbee = async function () {
    var run = this.collectRun();
    if (!run || !this.checkRequired(run)) {
      return;
    }

    if (!config.docbeeEnabled || !config.docbeeConfigured) {
      this.showMessage("DocBee ist im WordPress Backend nicht vollständig konfiguriert.", "bad");
      return;
    }

    var button = this.els.pushDocbee;
    try {
      if (button) {
        button.disabled = true;
        button.classList.add("is-busy");
      }
      this.showMessage("DocBee: sende Report...", "");
      var data = await this.api("docbee/message", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ run: run })
      });

      var suffix = data.url ? ' <a href="' + escAttr(data.url) + '" target="_blank" rel="noopener">Oeffnen</a>' : "";
      this.showMessage("DocBee: Report wurde gepostet." + suffix, "ok", true);
    } catch (error) {
      this.showMessage("DocBee-Request fehlgeschlagen: " + error.message, "bad");
    } finally {
      if (button) {
        button.disabled = false;
        button.classList.remove("is-busy");
      }
    }
  };

  QaTool.prototype.updateAutosave = function () {
    var state = {
      module: value(this.els.module),
      moduleVersion: value(this.els.moduleVersion),
      olmNummer: value(this.els.olmNummer),
      pbxVersion: value(this.els.pbxVersion),
      docbeeUrl: value(this.els.docbeeUrl)
    };

    try {
      window.localStorage.setItem(this.storageKey, JSON.stringify(state));
    } catch (error) {
      // Ignore private browsing quota errors.
    }
  };

  QaTool.prototype.restoreAutosave = function () {
    try {
      var state = JSON.parse(window.localStorage.getItem(this.storageKey) || "{}");
      if (state.module && this.els.module) {
        this.els.module.value = state.module;
      }
      if (state.moduleVersion && this.els.moduleVersion) {
        this.els.moduleVersion.value = state.moduleVersion;
      }
      if (state.olmNummer && this.els.olmNummer) {
        this.els.olmNummer.value = state.olmNummer;
      }
      if (state.pbxVersion && this.els.pbxVersion) {
        this.els.pbxVersion.value = state.pbxVersion;
      }
      if (state.docbeeUrl && this.els.docbeeUrl) {
        this.els.docbeeUrl.value = state.docbeeUrl;
      }
    } catch (error) {
      // Ignore broken old autosave data.
    }
  };

  QaTool.prototype.updateServiceBadges = function () {
    if (config.gitlabEnabled) {
      this.setTag(this.els.gitlabTplStatus, "GitLab: bereit", "");
    } else {
      this.setTag(this.els.gitlabTplStatus, "GitLab: deaktiviert", "warn");
    }

    if (!config.docbeeEnabled) {
      this.setTag(this.els.docbeeTokenStatus, "DocBee: deaktiviert", "warn");
    } else if (config.docbeeConfigured) {
      this.setTag(this.els.docbeeTokenStatus, "DocBee: konfiguriert", "ok");
    } else {
      this.setTag(this.els.docbeeTokenStatus, "DocBee: fehlt", "warn");
    }
  };

  QaTool.prototype.setTag = function (tag, text, state) {
    if (!tag) {
      return;
    }
    tag.textContent = text;
    tag.classList.remove("is-ok", "is-warn", "is-bad");
    if (state) {
      tag.classList.add("is-" + state);
    }
  };

  QaTool.prototype.showMessage = function (message, state, allowHtml) {
    if (!this.els.message) {
      return;
    }
    this.els.message.classList.remove("is-ok", "is-bad");
    if (state) {
      this.els.message.classList.add("is-" + state);
    }
    if (allowHtml) {
      this.els.message.innerHTML = message;
    } else {
      this.els.message.textContent = message;
    }
  };

  function bind(el, eventName, callback) {
    if (el) {
      el.addEventListener(eventName, callback);
    }
  }

  function value(el) {
    return el ? String(el.value || "").trim() : "";
  }

  function valueOf(root, selector) {
    return value(root.querySelector(selector));
  }

  function clone(valueToClone) {
    return JSON.parse(JSON.stringify(valueToClone));
  }

  function parseTemplateText(text) {
    var trimmed = String(text || "").trim();
    if (!trimmed) {
      throw new Error("Datei ist leer.");
    }

    if (window.jsyaml && typeof window.jsyaml.load === "function") {
      try {
        var yaml = window.jsyaml.load(trimmed);
        if (yaml && typeof yaml === "object") {
          return yaml;
        }
      } catch (error) {
        // Try JSON and the small fallback parser below.
      }
    }

    if (trimmed.charAt(0) === "{" || trimmed.charAt(0) === "[") {
      return JSON.parse(trimmed);
    }

    try {
      return JSON.parse(trimmed);
    } catch (error) {
      return parseSimpleYaml(trimmed);
    }
  }

  function normalizeTemplate(input, fallbackName) {
    if (!input || !Array.isArray(input.steps)) {
      throw new Error('Ungültige Vorlage: "steps" fehlt.');
    }

    return {
      name: stringOr(input.name, fallbackName || "Template"),
      module: stringOr(input.module, ""),
      module_version: stringOr(input.module_version, ""),
      olm_nummer: stringOr(input.olm_nummer || input.olm || input.olmNumber, ""),
      pbx_version: stringOr(input.pbx_version, ""),
      steps: input.steps.map(function (step, index) {
        var kind = step.kind || step.type || "step";
        if (kind === "group") {
          return {
            kind: "group",
            title: stringOr(step.title || step.name, "Gruppe " + (index + 1)),
            collapsed: !!step.collapsed
          };
        }

        return {
          kind: "step",
          id: stringOr(step.id, "step-" + String(index + 1).padStart(3, "0")),
          title: stringOr(step.title, ""),
          expected: stringOr(step.expected, ""),
          required: toBool(step.required),
          status: stringOr(step.status, ""),
          comment: stringOr(step.comment, ""),
          evidence: stringOr(step.evidence, "")
        };
      })
    };
  }

  function parseSimpleYaml(text) {
    var lines = String(text || "").replace(/\r\n?/g, "\n").split("\n");
    var out = {};
    var steps = null;
    var current = null;
    var index = 0;

    while (index < lines.length) {
      var raw = lines[index];
      var trimmed = raw.trim();
      var indent = countIndent(raw);

      if (!trimmed || trimmed.charAt(0) === "#") {
        index++;
        continue;
      }

      if (indent === 0) {
        var top = splitKeyValue(trimmed);
        if (!top) {
          index++;
          continue;
        }

        if (top.key === "steps") {
          steps = [];
          out.steps = steps;
          current = null;
          index++;
          continue;
        }

        if (top.value === "|" || top.value === ">") {
          var topBlock = readBlock(lines, index + 1, indent, top.value === ">");
          out[top.key] = topBlock.value;
          index = topBlock.next;
          continue;
        }

        out[top.key] = parseScalar(top.value);
        index++;
        continue;
      }

      if (steps && indent >= 2) {
        if (trimmed.indexOf("- ") === 0) {
          current = {};
          steps.push(current);
          var rest = trimmed.slice(2).trim();
          if (rest) {
            var inline = splitKeyValue(rest);
            if (inline) {
              current[inline.key] = parseScalar(inline.value);
            }
          }
          index++;
          continue;
        }

        if (current) {
          var pair = splitKeyValue(trimmed);
          if (pair) {
            if (pair.value === "|" || pair.value === ">") {
              var block = readBlock(lines, index + 1, indent, pair.value === ">");
              current[pair.key] = block.value;
              index = block.next;
              continue;
            }
            current[pair.key] = parseScalar(pair.value);
          }
        }
      }

      index++;
    }

    return out;
  }

  function countIndent(line) {
    var match = String(line || "").match(/^ */);
    return match ? match[0].length : 0;
  }

  function splitKeyValue(text) {
    var position = text.indexOf(":");
    if (position < 0) {
      return null;
    }
    return {
      key: text.slice(0, position).trim(),
      value: text.slice(position + 1).trim()
    };
  }

  function readBlock(lines, start, parentIndent, folded) {
    var collected = [];
    var index = start;
    var minIndent = null;

    while (index < lines.length) {
      var raw = lines[index];
      var trimmed = raw.trim();
      var indent = countIndent(raw);
      if (trimmed && indent <= parentIndent) {
        break;
      }
      if (!trimmed) {
        collected.push("");
        index++;
        continue;
      }
      if (minIndent === null || indent < minIndent) {
        minIndent = indent;
      }
      collected.push(raw);
      index++;
    }

    minIndent = minIndent || 0;
    var value = collected.map(function (line) {
      return line ? line.slice(minIndent) : "";
    }).join("\n").trim();

    if (folded) {
      value = value.replace(/\n+/g, " ");
    }

    return { value: value, next: index };
  }

  function parseScalar(value) {
    value = String(value || "").trim();
    if (value === "") {
      return "";
    }
    if (value === "true") {
      return true;
    }
    if (value === "false") {
      return false;
    }
    if (value === "null" || value === "~") {
      return "";
    }
    if ((value.charAt(0) === '"' && value.charAt(value.length - 1) === '"') || (value.charAt(0) === "'" && value.charAt(value.length - 1) === "'")) {
      value = value.slice(1, -1);
    }
    return value.replace(/\\"/g, '"').replace(/\\n/g, "\n");
  }

  function dumpTemplateYaml(template) {
    if (window.jsyaml && typeof window.jsyaml.dump === "function") {
      return window.jsyaml.dump(template, { lineWidth: 100 });
    }

    var lines = [
      'name: "' + yamlString(template.name || "") + '"',
      'module: "' + yamlString(template.module || "") + '"',
      'module_version: "' + yamlString(template.module_version || "") + '"',
      'olm_nummer: "' + yamlString(template.olm_nummer || "") + '"',
      'pbx_version: "' + yamlString(template.pbx_version || "") + '"',
      "steps:"
    ];

    (template.steps || []).forEach(function (step) {
      if (step.type === "group") {
        lines.push('  - type: "group"');
        lines.push('    title: "' + yamlString(step.title || "") + '"');
        return;
      }
      lines.push('  - type: "step"');
      lines.push('    id: "' + yamlString(step.id || "") + '"');
      lines.push('    title: "' + yamlString(step.title || "") + '"');
      lines.push('    expected: "' + yamlString(step.expected || "") + '"');
      lines.push("    required: " + (step.required ? "true" : "false"));
    });

    return lines.join("\n") + "\n";
  }

  function yamlString(valueToEscape) {
    return String(valueToEscape || "").replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n");
  }

  function toBool(valueToConvert) {
    if (typeof valueToConvert === "boolean") {
      return valueToConvert;
    }
    return ["1", "true", "yes", "ja"].indexOf(String(valueToConvert || "").toLowerCase()) !== -1;
  }

  function stringOr(valueToConvert, fallback) {
    if (valueToConvert === undefined || valueToConvert === null) {
      return fallback;
    }
    return String(valueToConvert);
  }

  function loadScript(url) {
    return new Promise(function (resolve, reject) {
      var script = document.createElement("script");
      script.src = url;
      script.async = true;
      script.onload = resolve;
      script.onerror = reject;
      document.head.appendChild(script);
    });
  }

  function imageToDataUrl(src) {
    return new Promise(function (resolve, reject) {
      if (!src) {
        reject(new Error("No image source."));
        return;
      }

      var image = new Image();
      image.crossOrigin = "anonymous";
      image.onload = function () {
        try {
          var canvas = document.createElement("canvas");
          canvas.width = image.naturalWidth || image.width;
          canvas.height = image.naturalHeight || image.height;
          var context = canvas.getContext("2d");
          context.drawImage(image, 0, 0);
          resolve(canvas.toDataURL("image/png"));
        } catch (error) {
          reject(error);
        }
      };
      image.onerror = reject;
      image.src = src;
    });
  }

  function replaceSymbols(valueToClean) {
    return String(valueToClean === undefined || valueToClean === null ? "" : valueToClean)
      .replace(/[\u2192\u21A6\u21E8\u27A1\u2794\u27F6\u27F7\u27F9\u279D\u279E\u279F\u27A0]/g, "->")
      .replace(/[\u2190\u21A4\u21E6\u2B05\u27F5]/g, "<-")
      .replace(/[\u2194\u21D4\u27F7\u27F8\u2B04]/g, "<->")
      .replace(/[\u2013\u2014\u2011]/g, "-")
      .replace(/\u2026/g, "...")
      .replace(/\u00D7/g, "x");
  }

  function toPdfSafe(valueToClean) {
    return String(valueToClean || "").replace(/[^\x09\x0A\x0D\x20-\x7E\u00A0-\u00FF]/g, "?");
  }

  function sanitizePdfText(valueToClean) {
    return toPdfSafe(replaceSymbols(String(valueToClean === undefined || valueToClean === null ? "" : valueToClean)
      .replace(/\u00A0/g, " ")
      .replace(/[\u201C\u201D]/g, '"')
      .replace(/[\u2018\u2019]/g, "'")
      .replace(/\s+/g, " ")
      .trim()));
  }

  function download(content, filename, type) {
    var blob = content instanceof Blob ? content : new Blob([content], { type: type });
    var url = URL.createObjectURL(blob);
    var link = document.createElement("a");
    link.href = url;
    link.download = filename;
    document.body.appendChild(link);
    link.click();
    link.remove();
    setTimeout(function () {
      URL.revokeObjectURL(url);
    }, 1500);
  }

  function runBaseName(prefix, run) {
    return [
      prefix,
      safeName(run.module || "modul"),
      safeName(run.module_version || "version"),
      safeName(run.pbx_version || "pbx")
    ].join("-");
  }

  function safeName(valueToClean) {
    return String(valueToClean || "")
      .trim()
      .toLowerCase()
      .replace(/\s+/g, "-")
      .replace(/[^\w.-]/g, "") || "qa";
  }

  function mdCell(valueToEscape) {
    return String(valueToEscape || "").replace(/\|/g, "\\|").replace(/\n/g, " ");
  }

  function csvCell(valueToEscape) {
    return '"' + String(valueToEscape === undefined || valueToEscape === null ? "" : valueToEscape).replace(/"/g, '""') + '"';
  }

  function countStatuses(steps) {
    var counts = { total: 0, pass: 0, fail: 0, skip: 0, blocked: 0, missingRequired: 0 };
    (steps || []).forEach(function (step) {
      if ((step.kind || step.type || "step") !== "step") {
        return;
      }
      counts.total++;
      if (Object.prototype.hasOwnProperty.call(counts, step.status)) {
        counts[step.status]++;
      }
      if (step.required && !step.status) {
        counts.missingRequired++;
      }
    });
    return counts;
  }

  function escHtml(valueToEscape) {
    return String(valueToEscape === undefined || valueToEscape === null ? "" : valueToEscape)
      .replace(/&/g, "&amp;")
      .replace(/</g, "&lt;")
      .replace(/>/g, "&gt;");
  }

  function escAttr(valueToEscape) {
    return escHtml(valueToEscape).replace(/"/g, "&quot;");
  }

})();
