# PsoftQL Validator

> Validate your PsoftQL JSON or XML syntax against the official schema

---

LLMS index: [llms.txt](/llms.txt)

---

<!-- rumdl-disable-file -->

Paste your PsoftQL JSON or XML below to validate it against the [official schema](https://sws.books.cedarhillsgroup.com/docs/psoftql/psoftql-syntax/). The format is auto-detected and validation runs automatically as you type.

<style>
#editor-wrapper {
  display: flex;
  border: 2px solid #ccc;
  border-radius: 4px;
  min-height: 300px;
  position: relative;
}
#editor-wrapper:focus-within {
  border-color: #289dd0;
}
#line-numbers {
  width: 48px;
  min-width: 48px;
  padding: 12px 8px 12px 8px;
  background: #f5f5f5;
  border-right: 1px solid #ddd;
  border-radius: 4px 0 0 4px;
  text-align: right;
  font-family: monospace;
  font-size: 14px;
  line-height: 21px;
  color: #999;
  user-select: none;
  overflow: hidden;
  white-space: pre;
}
#psoftql-input {
  flex: 1;
  min-height: 300px;
  font-family: monospace;
  font-size: 14px;
  line-height: 21px;
  padding: 12px;
  border: none;
  border-radius: 0 4px 4px 0;
  resize: vertical;
  tab-size: 2;
  outline: none;
}
#validation-result {
  margin-top: 16px;
  padding: 12px;
  border-radius: 4px;
  font-size: 14px;
}
.result-valid {
  background-color: #d4edda;
  border: 1px solid #c3e6cb;
  color: #155724;
}
.result-invalid {
  background-color: #f8d7da;
  border: 1px solid #f5c6cb;
  color: #721c24;
}
.result-empty {
  background-color: #e2e3e5;
  border: 1px solid #d6d8db;
  color: #383d41;
}
.error-list {
  list-style: none;
  padding: 0;
  margin: 8px 0 0 0;
}
.error-list li {
  padding: 8px 0;
  border-bottom: 1px solid rgba(0,0,0,0.1);
  font-size: 13px;
}
.error-list li:last-child {
  border-bottom: none;
}
.error-path {
  font-weight: bold;
  font-family: monospace;
}
.error-context {
  display: block;
  margin-top: 4px;
  padding: 6px 8px;
  background: rgba(0,0,0,0.05);
  border-radius: 3px;
  font-family: monospace;
  font-size: 12px;
  white-space: pre;
  overflow-x: auto;
}
.error-pointer {
  color: #dc3545;
  font-weight: bold;
}
.error-hint {
  display: block;
  margin-top: 4px;
  font-style: italic;
  color: #555;
}
#toolbar {
  margin-bottom: 8px;
  display: flex;
  gap: 8px;
  align-items: center;
}

#toolbar button {
  padding: 6px 16px;
  border: 1px solid #ccc;
  border-radius: 4px;
  background: #f8f9fa;
  cursor: pointer;
  font-size: 14px;
}
#toolbar button:hover {
  background: #e9ecef;
}
#toolbar button:disabled {
  opacity: 0.5;
  cursor: not-allowed;
}
#toolbar button:disabled:hover {
  background: #f8f9fa;
}
#toolbar-row2 {
  margin-bottom: 12px;
  display: flex;
  gap: 8px;
  align-items: center;
  flex-wrap: wrap;
}
#toolbar-row2 label {
  font-size: 14px;
  font-weight: bold;
}
#toolbar-row2 input {
  padding: 6px 10px;
  border: 1px solid #ccc;
  border-radius: 4px;
  font-size: 14px;
  font-family: monospace;
  width: 160px;
}
#toolbar-row2 input::placeholder {
  font-family: sans-serif;
  font-style: italic;
}
#toolbar-row2 button {
  padding: 6px 16px;
  border: 1px solid #ccc;
  border-radius: 4px;
  background: #f8f9fa;
  cursor: pointer;
  font-size: 14px;
}
#toolbar-row2 button:hover {
  background: #e9ecef;
}
.format-badge {
  display: inline-block;
  padding: 3px 10px;
  border-radius: 12px;
  font-size: 12px;
  font-weight: bold;
  letter-spacing: 0.5px;
  vertical-align: middle;
}
.format-json {
  background: #289dd0;
  color: #fff;
}
.format-xml {
  background: #5ac5c5;
  color: #fff;
}
.structured-output {
  margin-top: 16px;
  max-height: 600px;
  overflow-y: auto;
}
.so-section {
  border: 1px solid #c3e6cb;
  border-radius: 4px;
  margin-bottom: 10px;
  background: #fff;
}
.so-section:last-child {
  margin-bottom: 0;
}
.so-section-header {
  cursor: pointer;
  padding: 8px 14px;
  background: #e8f5e9;
  font-weight: bold;
  font-size: 14px;
  border-bottom: 1px solid #c3e6cb;
  display: flex;
  justify-content: space-between;
  align-items: center;
  user-select: none;
}
.so-section-header:hover {
  background: #c8e6c9;
}
.so-section.collapsed .so-section-body {
  display: none;
}
.so-section.collapsed .so-section-header {
  border-bottom: none;
}
.so-section-body {
  padding: 10px 14px;
  font-size: 13px;
}
.so-tree {
  font-family: monospace;
  font-size: 13px;
  line-height: 1.8;
}
.so-tree-connector {
  color: #999;
}
.so-record-name {
  font-weight: bold;
}
.so-tag {
  display: inline-block;
  padding: 1px 8px;
  border-radius: 10px;
  font-size: 11px;
  font-weight: normal;
  margin-left: 8px;
  vertical-align: middle;
}
.so-tag-auto { background: #e0e0e0; color: #555; }
.so-tag-custom { background: #fff3cd; color: #856404; }
.so-tag-nojoin { background: #f8d7da; color: #721c24; }
.so-kv {
  display: flex;
  gap: 8px;
  padding: 2px 0;
}
.so-key {
  font-weight: bold;
  min-width: 200px;
  flex-shrink: 0;
}
.so-val {
  font-family: monospace;
  word-break: break-all;
}
.so-criteria {
  font-family: monospace;
  font-size: 13px;
  padding: 2px 0;
}
.so-none {
  font-style: italic;
  color: #888;
}
.so-record-block {
  border: 1px solid #e0e0e0;
  border-radius: 4px;
  margin-bottom: 8px;
  padding: 10px 12px;
}
.so-record-block:last-child {
  margin-bottom: 0;
}
.so-record-block h4 {
  margin: 0 0 8px 0;
  font-size: 14px;
  font-family: monospace;
}
.so-record-block .so-detail-group {
  margin-bottom: 6px;
}
.so-record-block .so-detail-group:last-child {
  margin-bottom: 0;
}
.so-detail-label {
  font-weight: bold;
  font-size: 12px;
  color: #555;
  margin-bottom: 2px;
}
.so-agg-table {
  border-collapse: collapse;
  font-size: 13px;
  width: 100%;
}
.so-agg-table th, .so-agg-table td {
  border: 1px solid #ddd;
  padding: 4px 10px;
  text-align: left;
}
.so-agg-table th {
  background: #f5f5f5;
  font-weight: bold;
}
.builder-section {
  margin-bottom: 16px;
}
.builder-section .so-section-header::before {
  content: "▸";
  display: inline-block;
  margin-right: 8px;
  transition: transform 0.15s;
}
.builder-section:not(.collapsed) .so-section-header::before {
  transform: rotate(90deg);
}
.builder-grid {
  display: grid;
  grid-template-columns: max-content 1fr;
  gap: 8px 12px;
  align-items: start;
  margin-bottom: 14px;
}
.builder-grid label {
  font-weight: bold;
  font-size: 13px;
  padding-top: 6px;
  white-space: nowrap;
}
.builder-grid label .req {
  color: #dc3545;
  margin-left: 2px;
}
.builder-grid input[type="text"],
.builder-grid textarea {
  width: 100%;
  padding: 6px 10px;
  border: 1px solid #ccc;
  border-radius: 4px;
  font-size: 13px;
  font-family: monospace;
  box-sizing: border-box;
}
.builder-grid textarea {
  resize: vertical;
  min-height: 42px;
}
.builder-grid input[type="text"]::placeholder,
.builder-grid textarea::placeholder {
  font-family: sans-serif;
  font-style: italic;
  color: #999;
}
.builder-flags {
  display: flex;
  flex-wrap: wrap;
  gap: 16px;
  font-size: 13px;
}
.builder-flags label {
  font-weight: normal;
  cursor: pointer;
  user-select: none;
}
.builder-flags input[type="checkbox"] {
  margin-right: 4px;
  vertical-align: middle;
}
.builder-arrays {
  margin-top: 4px;
}
.builder-array {
  margin-bottom: 14px;
  padding: 10px 12px;
  background: #fafafa;
  border: 1px solid #e0e0e0;
  border-radius: 4px;
}
.builder-array-label {
  font-weight: bold;
  font-size: 13px;
  margin-bottom: 6px;
  display: block;
  color: #444;
}
.builder-array-rows {
  display: flex;
  flex-direction: column;
  gap: 6px;
}
.builder-array-row {
  display: flex;
  gap: 6px;
  align-items: center;
}
.builder-array-row input[type="text"],
.builder-array-row select {
  padding: 5px 8px;
  border: 1px solid #ccc;
  border-radius: 4px;
  font-size: 13px;
  font-family: monospace;
}
.builder-array-row input[type="text"] {
  flex: 1;
  min-width: 100px;
}
.builder-array-row select {
  background: #fff;
  cursor: pointer;
}
.builder-array-row .row-remove {
  padding: 4px 10px;
  border: 1px solid #ccc;
  border-radius: 4px;
  background: #fff;
  cursor: pointer;
  color: #721c24;
  font-weight: bold;
  font-size: 14px;
  line-height: 1;
}
.builder-array-row .row-remove:hover {
  background: #f8d7da;
}
.builder-array-add {
  margin-top: 6px;
  padding: 4px 12px;
  border: 1px dashed #999;
  border-radius: 4px;
  background: #fff;
  cursor: pointer;
  font-size: 12px;
  color: #555;
}
.builder-array-add:hover {
  background: #f0f0f0;
  border-color: #555;
}
.builder-actions {
  display: flex;
  gap: 8px;
  margin-top: 4px;
  padding-top: 12px;
  border-top: 1px solid #e0e0e0;
}
.builder-actions button {
  padding: 8px 18px;
  border: 1px solid #ccc;
  border-radius: 4px;
  cursor: pointer;
  font-size: 14px;
}
.builder-actions button.primary {
  background: #289dd0;
  color: #fff;
  border-color: #289dd0;
  font-weight: bold;
}
.builder-actions button.primary:hover {
  background: #2186b3;
}
.builder-actions button.primary:disabled {
  opacity: 0.5;
  cursor: not-allowed;
  background: #289dd0;
}
.builder-actions button.secondary {
  background: #f8f9fa;
}
.builder-actions button.secondary:hover {
  background: #e9ecef;
}
.builder-error {
  margin-top: 10px;
  padding: 8px 12px;
  background: #f8d7da;
  border: 1px solid #f5c6cb;
  color: #721c24;
  border-radius: 4px;
  font-size: 13px;
  display: none;
}
.builder-error.visible {
  display: block;
}
</style>

<div id="builder-section" class="so-section builder-section collapsed">
<div class="so-section-header" id="builder-toggle">
<span>Request Builder</span>
<span style="font-size: 12px; font-weight: normal; color: #555;">JSON only — appends to records[]</span>
</div>
<div class="so-section-body">
<div class="builder-grid">
<label for="b-recordName">Record Name<span class="req">*</span></label>
<input id="b-recordName" type="text" placeholder="e.g. JOB" />
<label for="b-parentRecordName">Parent Record</label>
<input id="b-parentRecordName" type="text" placeholder="optional — name of parent record" />
<label for="b-sqlWhereClause">SQL Where Clause</label>
<textarea id="b-sqlWhereClause" rows="2" placeholder="A.FIELD = 'X' (no WHERE keyword; alias is A)"></textarea>
<label>Flags</label>
<div class="builder-flags">
<label><input type="checkbox" id="b-useParentEffectiveDate" />useParentEffectiveDate</label>
<label><input type="checkbox" id="b-useNonKeyFieldsInJoin" />useNonKeyFieldsInJoin</label>
<label><input type="checkbox" id="b-doNotAutoJoinToParent" />doNotAutoJoinToParent</label>
</div>
</div>
<div class="builder-arrays">
<div class="builder-array" data-array="criteriaFields">
<span class="builder-array-label">Criteria Fields (filter conditions)</span>
<div class="builder-array-rows"></div>
<button type="button" class="builder-array-add">+ Add Criteria</button>
</div>
<div class="builder-array" data-array="joinFields">
<span class="builder-array-label">Join Fields (custom parent → child mapping)</span>
<div class="builder-array-rows"></div>
<button type="button" class="builder-array-add">+ Add Join</button>
</div>
<div class="builder-array" data-array="orderByFields">
<span class="builder-array-label">Order By Fields</span>
<div class="builder-array-rows"></div>
<button type="button" class="builder-array-add">+ Add Order By</button>
</div>
<div class="builder-array" data-array="excludeFields">
<span class="builder-array-label">Exclude Fields</span>
<div class="builder-array-rows"></div>
<button type="button" class="builder-array-add">+ Add Exclude</button>
</div>
<div class="builder-array" data-array="includeDescriptionsFor">
<span class="builder-array-label">Include Descriptions For</span>
<div class="builder-array-rows"></div>
<button type="button" class="builder-array-add">+ Add Field</button>
</div>
</div>
<div class="builder-error" id="builder-error"></div>
<div class="builder-actions">
<button type="button" class="primary" id="btn-builder-add">Add to Request</button>
<button type="button" class="secondary" id="btn-builder-reset">Reset Form</button>
</div>
</div>
</div>

<div id="toolbar">
  <button id="btn-format" title="Pretty-print with indentation">Format</button>
  <button id="btn-compact" title="Minify to a single line">Compact</button>
  <button id="btn-fill" title="Add all missing schema properties with default values (JSON only)">Fill All Properties</button>
  <button id="btn-clear" title="Clear the textarea">Clear</button>
  <span id="format-indicator" class="format-badge"></span>
</div>

<div id="toolbar-row2">
  <label>Add Record:</label>
  <input id="add-record-name" type="text" placeholder="Record Name" />
  <input id="add-parent-name" type="text" placeholder="Parent (optional)" />
  <button id="btn-add-record" title="Insert a new record entry">Add Record</button>
</div>

<div id="editor-wrapper">
  <div id="line-numbers">1</div>
  <textarea id="psoftql-input" placeholder='Paste PsoftQL JSON or XML here, for example:

JSON:
{
  "records": [
    { "recordName": "JOB" }
  ]
}

XML:
<?xml version="1.0" encoding="UTF-8" ?>
<request>
  <records>
    <record>
      <recordName>JOB</recordName>
    </record>
  </records>
</request>'></textarea>
</div>

<div id="validation-result" class="result-empty">
  Paste PsoftQL JSON or XML above to validate.
</div>

<script type="module">
import Ajv2020 from 'https://esm.sh/ajv@8/dist/2020?bundle';
import addFormats from 'https://esm.sh/ajv-formats@3?bundle';
import { parse as jsoncParse, printParseErrorCode } from 'https://esm.sh/jsonc-parser@3?bundle';

(function() {

  // ── Utilities ──

  function escapeHtml(str) {
    const div = document.createElement('div');
    div.textContent = str;
    return div.innerHTML;
  }

  function escapeXml(str) {
    return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
  }

  function detectFormat(text) {
    return text.trimStart().startsWith('<') ? 'xml' : 'json';
  }

  // ── Format indicator & toolbar state ──

  const formatBadge = document.getElementById('format-indicator');
  const btnFormat = document.getElementById('btn-format');
  const btnCompact = document.getElementById('btn-compact');
  const btnFill = document.getElementById('btn-fill');

  function updateFormatIndicator(format) {
    if (!format) {
      formatBadge.textContent = '';
      formatBadge.className = 'format-badge';
    } else if (format === 'xml') {
      formatBadge.textContent = 'XML';
      formatBadge.className = 'format-badge format-xml';
    } else {
      formatBadge.textContent = 'JSON';
      formatBadge.className = 'format-badge format-json';
    }
  }

  function updateToolbarForFormat(format) {
    const builderBtn = document.getElementById('btn-builder-add');
    if (format === 'xml') {
      btnFormat.textContent = 'Format XML';
      btnCompact.textContent = 'Compact XML';
      btnFill.disabled = true;
      btnFill.title = 'Fill All Properties is available for JSON only';
      if (builderBtn) {
        builderBtn.disabled = true;
        builderBtn.title = 'Request Builder supports JSON only';
      }
    } else {
      btnFormat.textContent = 'Format JSON';
      btnCompact.textContent = 'Compact JSON';
      btnFill.disabled = false;
      btnFill.title = 'Add all missing schema properties with default values';
      if (builderBtn) {
        builderBtn.disabled = false;
        builderBtn.title = 'Build a record and append it to records[] in the editor';
      }
    }
  }

  // ── JSON helpers ──

  const errorHintsByName = {
    'InvalidSymbol': 'Check for a missing or extra comma, bracket, or brace near this location.',
    'InvalidNumberFormat': 'The number format is invalid. Numbers cannot have leading zeros or other formatting issues.',
    'PropertyNameExpected': 'Expected a property name enclosed in double quotes (") here.',
    'ValueExpected': 'Expected a value (string, number, boolean, null, object, or array) here.',
    'ColonExpected': 'Expected a colon (:) after the property name.',
    'CommaExpected': 'Expected a comma (,) or closing brace/bracket here.',
    'CloseBraceExpected': 'Expected a closing brace (}) here.',
    'CloseBracketExpected': 'Expected a closing bracket (]) here.',
    'EndOfFileExpected': 'There is extra content after the JSON value ends. Check for duplicate closing braces or extra text.',
    'InvalidCommentToken': 'Comments are not allowed in standard JSON.',
    'UnexpectedEndOfComment': 'A block comment was opened but never closed.',
    'UnexpectedEndOfString': 'A string was opened but never closed. Check for a missing closing quote (").',
    'UnexpectedEndOfNumber': 'A number was started but is incomplete.',
    'InvalidUnicode': 'Invalid unicode escape sequence in a string.',
    'InvalidEscapeCharacter': 'Invalid escape character in a string. Valid escapes are: \\", \\\\, \\/, \\b, \\f, \\n, \\r, \\t, \\uXXXX.',
    'InvalidCharacter': 'Invalid character. If you see curly braces like {{...}}, these are template placeholders that must be replaced with actual JSON values (e.g. true, false, a number, or a quoted string).'
  };

  function getLineAndCol(text, offset) {
    let line = 1, col = 1;
    for (let i = 0; i < offset && i < text.length; i++) {
      if (text[i] === '\n') { line++; col = 1; } else { col++; }
    }
    return { line, col };
  }

  function getContextSnippet(text, offset) {
    const lines = text.split('\n');
    const { line, col } = getLineAndCol(text, offset);
    const lineIdx = line - 1;
    let snippet = '';
    if (lineIdx > 0) {
      snippet += '  ' + lines[lineIdx - 1] + '\n';
    }
    snippet += '> ' + lines[lineIdx] + '\n';
    snippet += '  ' + ' '.repeat(Math.max(0, col - 1)) + '^';
    return snippet;
  }

  function parseJsonWithDiagnostics(text) {
    const errors = [];
    const data = jsoncParse(text, errors, { allowTrailingComma: false });
    if (errors.length > 0) {
      return {
        valid: false,
        errors: errors.map(err => {
          const { line, col } = getLineAndCol(text, err.offset);
          const codeName = printParseErrorCode(err.error);
          const hint = errorHintsByName[codeName] || 'Unexpected syntax error.';
          const snippet = getContextSnippet(text, err.offset);
          return { line, col, codeName, hint, snippet };
        })
      };
    }
    return { valid: true, data };
  }

  // ── JSON Schema ──

  const schema = {
    "$id": "https://cedarhillsgroup.com/psoftql.schema.json",
    "$schema": "https://json-schema.org/draft/2020-12/schema",
    "title": "PeopleSoft Query Language - PSOFTQL",
    "description": "PeopleSoft Query Language - PSOFTQL",
    "type": "object",
    "properties": {
      "isDebugMode": {
        "type": "boolean",
        "description": "Optional - Turn on and off debug mode."
      },
      "rowLimit": {
        "type": "integer",
        "description": "Override the default 50 row limit."
      },
      "pageNumber": {
        "type": "integer",
        "description": "Request a different page number other than the first page."
      },
      "includeFieldTypes": {
        "type": "boolean",
        "description": "Optional - set to true if you want the PeopleSoft field types to come back."
      },
      "includeKeyFieldIndicators": {
        "type": "boolean",
        "description": "Optional - Set to true if you want indicators on key fields to come back."
      },
      "includeAllDescriptions": {
        "type": "boolean",
        "description": "Optional - Set to true if you want to include all field descriptions."
      },
      "includeAllFieldLabels": {
        "type": "boolean",
        "description": "Optional - Set to true if you want default labels to come back."
      },
      "noEffectiveDateLogic": {
        "type": "boolean",
        "description": "Optional - Set to true to include all historical rows."
      },
      "noEffectiveStatusLogic": {
        "type": "boolean",
        "description": "Optional - Set to true to include rows where EFF_STATUS = I."
      },
      "noEffectiveSequenceLogic": {
        "type": "boolean",
        "description": "Optional - Set to true to include all EFFSEQ rows."
      },
      "effectiveDateOverride": {
        "type": "string",
        "format": "date",
        "description": "Optional date in YYYY-MM-DD format to override effective date logic."
      },
      "isAggregate": {
        "type": "boolean",
        "description": "Set to true to enable aggregate query mode."
      },
      "records": {
        "type": "array",
        "description": "Required list of records you want from the database.",
        "items": {
          "type": "object",
          "properties": {
            "recordName": {
              "type": "string",
              "description": "The record name to return data from."
            },
            "parentRecordName": {
              "type": "string",
              "description": "The parent record name for child records."
            },
            "useParentEffectiveDate": {
              "type": "boolean",
              "description": "Force parent effective date on child records."
            },
            "useNonKeyFieldsInJoin": {
              "type": "boolean",
              "description": "Use non-key fields when joining child to parent."
            },
            "sqlWhereClause": {
              "type": "string",
              "description": "Optional WHERE clause (without 'WHERE' keyword)."
            },
            "doNotAutoJoinToParent": {
              "type": "boolean",
              "description": "Disable automatic join to parent record."
            },
            "joinFields": {
              "type": "array",
              "items": {
                "type": "object",
                "properties": {
                  "parentField": { "type": "string" },
                  "childField": { "type": "string" }
                },
                "required": ["parentField", "childField"]
              }
            },
            "criteriaFields": {
              "type": "array",
              "items": {
                "type": "object",
                "properties": {
                  "fieldName": { "type": "string" },
                  "fieldValue": { "type": "string" },
                  "operator": {
                    "type": "string",
                    "examples": ["=", "<", ">", "<=", ">=", "LIKE", "IN", "<>", "!="]
                  }
                },
                "required": ["fieldName", "fieldValue", "operator"]
              }
            },
            "excludeFields": {
              "type": "array",
              "items": { "type": "string" }
            },
            "includeDescriptionsFor": {
              "type": "array",
              "items": { "type": "string" }
            },
            "orderByFields": {
              "type": "array",
              "items": {
                "type": "object",
                "properties": {
                  "fieldName": { "type": "string" },
                  "sortOrder": {
                    "type": "string",
                    "enum": ["ASC", "DESC"],
                    "default": "ASC"
                  }
                },
                "required": ["fieldName"]
              }
            },
            "aggregateConfig": {
              "type": "object",
              "properties": {
                "groupByFields": {
                  "type": "array",
                  "items": { "type": "string" }
                },
                "aggregateFields": {
                  "type": "array",
                  "minItems": 1,
                  "items": {
                    "type": "object",
                    "properties": {
                      "function": {
                        "type": "string",
                        "examples": ["COUNT(*)", "SUM(ANNUAL_RT)", "AVG(SALARY)", "MIN(HIRE_DT)", "MAX(LASTUPDDTTM)"]
                      },
                      "outputLabel": { "type": "string" }
                    },
                    "required": ["function"]
                  }
                }
              },
              "required": ["aggregateFields"]
            }
          },
          "required": ["recordName"],
          "additionalProperties": false
        }
      }
    },
    "required": ["records"],
    "additionalProperties": false
  };

  const ajv = new Ajv2020({ allErrors: true });
  addFormats(ajv);
  const validate = ajv.compile(schema);

  // ── XML Schema Definition ──
  // Root-level properties are accepted both under <request> and inside <records>
  // since official documentation examples show both patterns.

  const rootLevelProps = {
    isDebugMode: 'boolean',
    rowLimit: 'integer',
    pageNumber: 'integer',
    includeFieldTypes: 'boolean',
    includeKeyFieldIndicators: 'boolean',
    includeAllDescriptions: 'boolean',
    includeAllFieldLabels: 'boolean',
    noEffectiveDateLogic: 'boolean',
    noEffectiveStatusLogic: 'boolean',
    noEffectiveSequenceLogic: 'boolean',
    effectiveDateOverride: 'string',
    isAggregate: 'boolean'
  };

  // Elements inside <record> that use attributes (self-closing tags)
  const attributeElements = {
    excludeFields: { required: ['fieldName'], optional: [] },
    includeDescriptionsFor: { required: ['fieldName'], optional: [] },
    criteriaFields: { required: ['fieldName', 'fieldValue', 'operator'], optional: [] },
    joinFields: { required: ['parentField', 'childField'], optional: [] },
    orderByFields: { required: ['fieldName'], optional: ['sortOrder'] }
  };

  // Simple child elements inside <record> with text content
  const recordTextElements = {
    recordName: 'string',
    parentRecordName: 'string',
    useParentEffectiveDate: 'boolean',
    useNonKeyFieldsInJoin: 'boolean',
    doNotAutoJoinToParent: 'boolean',
    sqlWhereClause: 'string'
  };

  // <aggregateConfig> child elements (attribute-based)
  const aggregateConfigElements = {
    groupByFields: { required: ['fieldName'], optional: [] },
    aggregateFields: { required: ['function'], optional: ['outputLabel'] }
  };

  // ── XML Parsing ──

  function parseXml(text) {
    const parser = new DOMParser();
    const doc = parser.parseFromString(text, 'application/xml');
    const parseError = doc.querySelector('parsererror');
    if (parseError) {
      let msg = parseError.textContent || 'XML is not well-formed.';
      const lineMatch = msg.match(/line\s+(\d+)/i);
      const colMatch = msg.match(/column\s+(\d+)/i);
      return {
        valid: false,
        errors: [{
          message: msg.split('\n')[0].trim(),
          line: lineMatch ? parseInt(lineMatch[1]) : null,
          col: colMatch ? parseInt(colMatch[1]) : null
        }]
      };
    }
    return { valid: true, doc };
  }

  // ── XML Structure Validation ──

  function validateXmlStructure(doc) {
    const errors = [];
    const root = doc.documentElement;

    if (root.tagName !== 'request') {
      errors.push({ path: '/', message: 'Root element must be <request>, found <' + root.tagName + '>.' });
      return errors;
    }

    let hasRecords = false;

    for (const child of root.children) {
      const tag = child.tagName;
      if (tag === 'records') {
        hasRecords = true;
        validateRecordsElement(child, errors);
      } else if (tag in rootLevelProps) {
        validateTextType(child, rootLevelProps[tag], '/request/' + tag, errors);
      } else {
        errors.push({ path: '/request', message: 'Unknown element <' + tag + '>.' });
      }
    }

    if (!hasRecords) {
      errors.push({ path: '/request', message: 'Missing required element <records>.' });
    }

    return errors;
  }

  function validateRecordsElement(recordsEl, errors) {
    const basePath = '/request/records';
    let recordCount = 0;

    for (const child of recordsEl.children) {
      const tag = child.tagName;
      if (tag === 'record') {
        recordCount++;
        validateRecordElement(child, basePath + '/record[' + recordCount + ']', errors);
      } else if (tag in rootLevelProps) {
        // Root-level props also accepted inside <records> per official docs
        validateTextType(child, rootLevelProps[tag], basePath + '/' + tag, errors);
      } else {
        errors.push({ path: basePath, message: 'Unknown element <' + tag + '>.' });
      }
    }

    if (recordCount === 0) {
      errors.push({ path: basePath, message: 'Must contain at least one <record> element.' });
    }
  }

  function validateRecordElement(recordEl, path, errors) {
    let hasRecordName = false;

    for (const child of recordEl.children) {
      const tag = child.tagName;
      if (tag === 'recordName') {
        hasRecordName = true;
        const text = getElementText(child);
        if (!text) {
          errors.push({ path: path + '/recordName', message: 'recordName must not be empty.' });
        }
      } else if (tag in recordTextElements) {
        validateTextType(child, recordTextElements[tag], path + '/' + tag, errors);
      } else if (tag in attributeElements) {
        validateAttributeElement(child, tag, path + '/' + tag, errors);
      } else if (tag === 'aggregateConfig') {
        validateAggregateConfig(child, path + '/aggregateConfig', errors);
      } else {
        errors.push({ path: path, message: 'Unknown element <' + tag + '>.' });
      }
    }

    if (!hasRecordName) {
      errors.push({ path: path, message: 'Missing required element <recordName>.' });
    }
  }

  function validateAttributeElement(el, tag, path, errors) {
    const spec = attributeElements[tag] || aggregateConfigElements[tag];
    if (!spec) return;

    for (const attr of spec.required) {
      if (!el.hasAttribute(attr)) {
        errors.push({ path: path, message: 'Missing required attribute "' + attr + '".' });
      }
    }

    const allAllowed = new Set([...spec.required, ...spec.optional]);
    for (const attr of el.attributes) {
      if (!allAllowed.has(attr.name)) {
        errors.push({ path: path, message: 'Unknown attribute "' + attr.name + '".' });
      }
    }
  }

  function validateAggregateConfig(el, path, errors) {
    let hasAggregateFields = false;

    for (const child of el.children) {
      const tag = child.tagName;
      if (tag in aggregateConfigElements) {
        if (tag === 'aggregateFields') hasAggregateFields = true;
        validateAttributeElement(child, tag, path + '/' + tag, errors);
      } else {
        errors.push({ path: path, message: 'Unknown element <' + tag + '>.' });
      }
    }

    if (!hasAggregateFields) {
      errors.push({ path: path, message: 'Must contain at least one <aggregateFields> element.' });
    }
  }

  function validateTextType(el, type, path, errors) {
    const text = getElementText(el);
    if (type === 'boolean') {
      if (text !== '' && text !== 'true' && text !== 'false') {
        errors.push({ path: path, message: 'Expected "true" or "false", found "' + text + '".' });
      }
    } else if (type === 'integer') {
      if (text !== '' && !/^-?\d+$/.test(text)) {
        errors.push({ path: path, message: 'Expected an integer, found "' + text + '".' });
      }
    }
  }

  function getElementText(el) {
    let text = '';
    for (const node of el.childNodes) {
      if (node.nodeType === Node.TEXT_NODE || node.nodeType === Node.CDATA_SECTION_NODE) {
        text += node.textContent;
      }
    }
    return text.trim();
  }

  // ── XML to JSON Normalizer ──

  function coerceValue(text, type) {
    if (type === 'boolean') return text === 'true';
    if (type === 'integer') return parseInt(text, 10) || 0;
    return text;
  }

  function extractDataFromXml(doc) {
    const root = doc.documentElement;
    const data = {};

    function readRootProps(parent) {
      for (const child of parent.children) {
        const tag = child.tagName;
        if (tag in rootLevelProps) {
          data[tag] = coerceValue(getElementText(child), rootLevelProps[tag]);
        }
      }
    }

    readRootProps(root);

    const recordsEl = root.querySelector('records');
    if (!recordsEl) { data.records = []; return data; }

    readRootProps(recordsEl);

    data.records = [];
    for (const recEl of recordsEl.querySelectorAll(':scope > record')) {
      const rec = {};
      for (const child of recEl.children) {
        const tag = child.tagName;
        if (tag in recordTextElements) {
          rec[tag] = coerceValue(getElementText(child), recordTextElements[tag]);
        } else if (tag === 'excludeFields') {
          if (!rec.excludeFields) rec.excludeFields = [];
          const fn = child.getAttribute('fieldName');
          if (fn) rec.excludeFields.push(fn);
        } else if (tag === 'includeDescriptionsFor') {
          if (!rec.includeDescriptionsFor) rec.includeDescriptionsFor = [];
          const fn = child.getAttribute('fieldName');
          if (fn) rec.includeDescriptionsFor.push(fn);
        } else if (tag === 'criteriaFields') {
          if (!rec.criteriaFields) rec.criteriaFields = [];
          rec.criteriaFields.push({
            fieldName: child.getAttribute('fieldName') || '',
            fieldValue: child.getAttribute('fieldValue') || '',
            operator: child.getAttribute('operator') || ''
          });
        } else if (tag === 'joinFields') {
          if (!rec.joinFields) rec.joinFields = [];
          rec.joinFields.push({
            parentField: child.getAttribute('parentField') || '',
            childField: child.getAttribute('childField') || ''
          });
        } else if (tag === 'orderByFields') {
          if (!rec.orderByFields) rec.orderByFields = [];
          rec.orderByFields.push({
            fieldName: child.getAttribute('fieldName') || '',
            sortOrder: child.getAttribute('sortOrder') || 'ASC'
          });
        } else if (tag === 'aggregateConfig') {
          rec.aggregateConfig = { groupByFields: [], aggregateFields: [] };
          for (const ac of child.children) {
            if (ac.tagName === 'groupByFields') {
              const fn = ac.getAttribute('fieldName');
              if (fn) rec.aggregateConfig.groupByFields.push(fn);
            } else if (ac.tagName === 'aggregateFields') {
              const af = { function: ac.getAttribute('function') || '' };
              const ol = ac.getAttribute('outputLabel');
              if (ol) af.outputLabel = ol;
              rec.aggregateConfig.aggregateFields.push(af);
            }
          }
        }
      }
      data.records.push(rec);
    }
    return data;
  }

  // ── Structured Output ──

  function buildRecordTree(records) {
    if (!records || records.length === 0) return [];
    const map = {};
    const enriched = records.map(function(r) {
      const node = Object.assign({}, r, { children: [] });
      map[r.recordName] = node;
      return node;
    });
    const roots = [];
    for (const node of enriched) {
      if (node.parentRecordName && map[node.parentRecordName]) {
        map[node.parentRecordName].children.push(node);
      } else {
        roots.push(node);
      }
    }
    return roots;
  }

  function renderGlobalSettings(data) {
    const settings = [];
    const labels = {
      isDebugMode: 'Debug Mode',
      rowLimit: 'Row Limit',
      pageNumber: 'Page Number',
      includeFieldTypes: 'Include Field Types',
      includeKeyFieldIndicators: 'Include Key Field Indicators',
      includeAllDescriptions: 'Include All Descriptions',
      includeAllFieldLabels: 'Include All Field Labels',
      noEffectiveDateLogic: 'No Effective Date Logic',
      noEffectiveStatusLogic: 'No Effective Status Logic',
      noEffectiveSequenceLogic: 'No Effective Sequence Logic',
      effectiveDateOverride: 'Effective Date Override',
      isAggregate: 'Aggregate Mode'
    };
    for (const key in labels) {
      if (key in data && data[key] !== undefined) {
        let val = data[key];
        if (typeof val === 'boolean') val = val ? 'ON' : 'OFF';
        settings.push('<div class="so-kv"><span class="so-key">' + escapeHtml(labels[key]) + ':</span><span class="so-val">' + escapeHtml(String(val)) + '</span></div>');
      }
    }
    if (settings.length === 0) {
      return '<span class="so-none">All defaults (no global settings specified)</span>';
    }
    return settings.join('');
  }

  function renderTreeNodes(nodes, prefix, isLast) {
    let html = '';
    for (let i = 0; i < nodes.length; i++) {
      const node = nodes[i];
      const last = i === nodes.length - 1;
      const connector = prefix === '' ? '' : (last ? '<span class="so-tree-connector">\u2514\u2500\u2500 </span>' : '<span class="so-tree-connector">\u251c\u2500\u2500 </span>');
      let joinTag = '';
      if (node.parentRecordName) {
        if (node.doNotAutoJoinToParent) {
          joinTag = '<span class="so-tag so-tag-nojoin">no auto-join</span>';
        } else if (node.joinFields && node.joinFields.length > 0) {
          joinTag = '<span class="so-tag so-tag-custom">custom join</span>';
        } else {
          joinTag = '<span class="so-tag so-tag-auto">auto-join</span>';
        }
      }
      let orphanNote = '';
      if (node.parentRecordName && !nodes._fromParent) {
        // Check if this is a root but has parentRecordName (orphan)
        if (prefix === '' && node.parentRecordName) {
          orphanNote = ' <span class="so-none">(parent "' + escapeHtml(node.parentRecordName) + '" not in query)</span>';
        }
      }
      html += '<div>' + escapeHtml(prefix) + connector + '<span class="so-record-name">' + escapeHtml(node.recordName || '(unnamed)') + '</span>' + joinTag + orphanNote + '</div>';
      if (node.children.length > 0) {
        const childPrefix = prefix + (prefix === '' ? '    ' : (last ? '    ' : '\u2502 '));
        html += renderTreeNodes(node.children, childPrefix, last);
      }
    }
    return html;
  }

  function renderRecordHierarchy(records) {
    const tree = buildRecordTree(records);
    if (tree.length === 0) return '<span class="so-none">No records</span>';
    return '<div class="so-tree">' + renderTreeNodes(tree, '', false) + '</div>';
  }

  function renderRecordDetails(records) {
    if (!records || records.length === 0) return '<span class="so-none">No records</span>';
    let html = '';
    for (const rec of records) {
      html += '<div class="so-record-block">';
      html += '<h4>' + escapeHtml(rec.recordName || '(unnamed)') + '</h4>';
      let hasDetails = false;

      if (rec.criteriaFields && rec.criteriaFields.length > 0) {
        hasDetails = true;
        html += '<div class="so-detail-group"><div class="so-detail-label">Criteria Filters:</div>';
        for (const c of rec.criteriaFields) {
          html += '<div class="so-criteria">' + escapeHtml(c.fieldName) + ' ' + escapeHtml(c.operator) + ' "' + escapeHtml(c.fieldValue) + '"</div>';
        }
        html += '</div>';
      }

      if (rec.sqlWhereClause) {
        hasDetails = true;
        html += '<div class="so-detail-group"><div class="so-detail-label">SQL WHERE:</div>';
        html += '<div class="so-criteria">' + escapeHtml(rec.sqlWhereClause) + '</div></div>';
      }

      if (rec.excludeFields && rec.excludeFields.length > 0) {
        hasDetails = true;
        html += '<div class="so-detail-group"><div class="so-detail-label">Excluded Fields:</div>';
        html += '<div class="so-val">' + rec.excludeFields.map(function(f) { return escapeHtml(f); }).join(', ') + '</div></div>';
      }

      if (rec.includeDescriptionsFor && rec.includeDescriptionsFor.length > 0) {
        hasDetails = true;
        html += '<div class="so-detail-group"><div class="so-detail-label">Include Descriptions For:</div>';
        html += '<div class="so-val">' + rec.includeDescriptionsFor.map(function(f) { return escapeHtml(f); }).join(', ') + '</div></div>';
      }

      if (rec.orderByFields && rec.orderByFields.length > 0) {
        hasDetails = true;
        html += '<div class="so-detail-group"><div class="so-detail-label">Order By:</div>';
        for (const o of rec.orderByFields) {
          html += '<div class="so-criteria">' + escapeHtml(o.fieldName) + ' ' + escapeHtml(o.sortOrder || 'ASC') + '</div>';
        }
        html += '</div>';
      }

      if (rec.joinFields && rec.joinFields.length > 0) {
        hasDetails = true;
        html += '<div class="so-detail-group"><div class="so-detail-label">Custom Join Fields:</div>';
        for (const j of rec.joinFields) {
          html += '<div class="so-criteria">' + escapeHtml(j.parentField) + ' \u2192 ' + escapeHtml(j.childField) + '</div>';
        }
        html += '</div>';
      }

      const boolFlags = [];
      if (rec.useParentEffectiveDate) boolFlags.push('Use Parent Effective Date');
      if (rec.useNonKeyFieldsInJoin) boolFlags.push('Use Non-Key Fields in Join');
      if (rec.doNotAutoJoinToParent) boolFlags.push('Do Not Auto-Join to Parent');
      if (boolFlags.length > 0) {
        hasDetails = true;
        html += '<div class="so-detail-group"><div class="so-detail-label">Flags:</div>';
        html += '<div class="so-val">' + boolFlags.join(', ') + '</div></div>';
      }

      if (!hasDetails) {
        html += '<span class="so-none">No additional configuration</span>';
      }

      html += '</div>';
    }
    return html;
  }

  function renderAggregateSummary(data) {
    if (!data.isAggregate) return '';
    let html = '';
    for (const rec of (data.records || [])) {
      if (!rec.aggregateConfig) continue;
      html += '<div class="so-record-block">';
      html += '<h4>' + escapeHtml(rec.recordName || '(unnamed)') + '</h4>';
      if (rec.aggregateConfig.groupByFields && rec.aggregateConfig.groupByFields.length > 0) {
        html += '<div class="so-detail-group"><div class="so-detail-label">GROUP BY:</div>';
        html += '<div class="so-val">' + rec.aggregateConfig.groupByFields.map(function(f) { return escapeHtml(f); }).join(', ') + '</div></div>';
      }
      if (rec.aggregateConfig.aggregateFields && rec.aggregateConfig.aggregateFields.length > 0) {
        html += '<div class="so-detail-group"><div class="so-detail-label">Aggregate Functions:</div>';
        html += '<table class="so-agg-table"><tr><th>Function</th><th>Output Label</th></tr>';
        for (const af of rec.aggregateConfig.aggregateFields) {
          html += '<tr><td>' + escapeHtml(af.function || '') + '</td><td>' + escapeHtml(af.outputLabel || '(auto)') + '</td></tr>';
        }
        html += '</table></div>';
      }
      html += '</div>';
    }
    return html;
  }

  function makeSection(title, bodyHtml) {
    return '<div class="so-section">' +
      '<div class="so-section-header"><span>' + escapeHtml(title) + '</span><span class="so-toggle">\u25bc</span></div>' +
      '<div class="so-section-body">' + bodyHtml + '</div></div>';
  }

  function renderStructuredOutput(data) {
    let html = '<div class="structured-output">';
    html += makeSection('Global Settings', renderGlobalSettings(data));
    html += makeSection('Record Hierarchy', renderRecordHierarchy(data.records || []));
    html += makeSection('Record Details', renderRecordDetails(data.records || []));
    const aggHtml = renderAggregateSummary(data);
    if (aggHtml) {
      html += makeSection('Aggregate Configuration', aggHtml);
    }
    html += '</div>';
    return html;
  }

  // ── XML Formatting ──

  function formatXmlFromDoc(doc) {
    const lines = [];
    lines.push('<?xml version="1.0" encoding="UTF-8" ?>');
    serializeNode(doc.documentElement, lines, 0);
    return lines.join('\n');
  }

  function serializeNode(node, lines, depth) {
    const indent = '    '.repeat(depth);
    const tag = node.tagName;

    let attrs = '';
    for (const attr of node.attributes) {
      attrs += ' ' + attr.name + '="' + escapeXml(attr.value) + '"';
    }

    const childElements = Array.from(node.children);
    const textContent = getElementText(node);

    if (childElements.length > 0) {
      lines.push(indent + '<' + tag + attrs + '>');
      for (const child of childElements) {
        serializeNode(child, lines, depth + 1);
      }
      lines.push(indent + '</' + tag + '>');
    } else if (textContent) {
      let hasCdata = false;
      for (const child of node.childNodes) {
        if (child.nodeType === Node.CDATA_SECTION_NODE) { hasCdata = true; break; }
      }
      if (hasCdata) {
        lines.push(indent + '<' + tag + attrs + '><![CDATA[' + textContent + ']]></' + tag + '>');
      } else {
        lines.push(indent + '<' + tag + attrs + '>' + escapeXml(textContent) + '</' + tag + '>');
      }
    } else if (node.attributes.length > 0) {
      lines.push(indent + '<' + tag + attrs + ' />');
    } else {
      lines.push(indent + '<' + tag + attrs + '></' + tag + '>');
    }
  }

  function compactXml(doc) {
    const serializer = new XMLSerializer();
    let xml = serializer.serializeToString(doc);
    xml = xml.replace(/>\s+</g, '><');
    return xml;
  }

  // ── Add Record for XML ──

  function addXmlRecord(text, recordName, parentName) {
    let newRecord = '        <record>\n';
    newRecord += '            <recordName>' + escapeXml(recordName) + '</recordName>\n';
    if (parentName) {
      newRecord += '            <parentRecordName>' + escapeXml(parentName) + '</parentRecordName>\n';
    }
    newRecord += '        </record>\n';

    if (!text) {
      return '<?xml version="1.0" encoding="UTF-8" ?>\n<request>\n    <records>\n' + newRecord + '    </records>\n</request>';
    }

    const insertPoint = text.lastIndexOf('</records>');
    if (insertPoint === -1) {
      return null;
    }
    return text.substring(0, insertPoint) + newRecord + text.substring(insertPoint);
  }

  // ── DOM references ──

  const input = document.getElementById('psoftql-input');
  const result = document.getElementById('validation-result');
  const lineNumbers = document.getElementById('line-numbers');
  let debounceTimer;
  let currentFormat = null;

  function updateLineNumbers() {
    const lines = input.value.split('\n');
    const count = Math.max(lines.length, 1);
    const nums = [];
    for (let i = 1; i <= count; i++) nums.push(i);
    lineNumbers.textContent = nums.join('\n');
  }

  input.addEventListener('input', function() {
    updateLineNumbers();
    clearTimeout(debounceTimer);
    debounceTimer = setTimeout(runValidation, 300);
  });

  input.addEventListener('scroll', function() {
    lineNumbers.scrollTop = input.scrollTop;
  });

  result.addEventListener('click', function(e) {
    var header = e.target.closest('.so-section-header');
    if (!header) return;
    var section = header.parentElement;
    var isCollapsed = section.classList.toggle('collapsed');
    header.querySelector('.so-toggle').textContent = isCollapsed ? '\u25b6' : '\u25bc';
  });

  // ── Validation dispatcher ──

  function runValidation() {
    const text = input.value.trim();

    if (!text) {
      currentFormat = null;
      result.className = 'result-empty';
      result.innerHTML = 'Paste PsoftQL JSON or XML above to validate.';
      updateFormatIndicator(null);
      updateToolbarForFormat('json');
      return;
    }

    currentFormat = detectFormat(text);
    updateFormatIndicator(currentFormat);
    updateToolbarForFormat(currentFormat);

    if (currentFormat === 'xml') {
      runXmlValidation(text);
    } else {
      runJsonValidation(text);
    }
  }

  function runJsonValidation(text) {
    const parseResult = parseJsonWithDiagnostics(text);

    if (!parseResult.valid) {
      result.className = 'result-invalid';
      let html = '<strong>JSON Syntax Errors:</strong><ul class="error-list">';
      for (const err of parseResult.errors) {
        html += '<li>';
        html += '<span class="error-path">Line ' + err.line + ', Column ' + err.col + '</span>';
        html += ' &mdash; ' + escapeHtml(err.codeName);
        html += '<span class="error-context">' + escapeHtml(err.snippet) + '</span>';
        html += '<span class="error-hint">Hint: ' + escapeHtml(err.hint) + '</span>';
        html += '</li>';
      }
      html += '</ul>';
      result.innerHTML = html;
      return;
    }

    const valid = validate(parseResult.data);

    if (valid) {
      result.className = 'result-valid';
      result.innerHTML = '<strong>Valid PsoftQL JSON syntax.</strong>' + renderStructuredOutput(parseResult.data);
    } else {
      result.className = 'result-invalid';
      let html = '<strong>Schema Validation Errors:</strong><ul class="error-list">';
      for (const err of validate.errors) {
        const path = err.instancePath || '/';
        let msg = err.message;
        if (err.keyword === 'additionalProperties') {
          msg = 'unknown property: "' + err.params.additionalProperty + '" is not a recognized PsoftQL property';
        } else if (err.keyword === 'type') {
          msg = 'expected type "' + err.params.type + '" but got "' + typeof err.data + '"';
        } else if (err.keyword === 'enum') {
          msg += '. Allowed values: ' + err.params.allowedValues.join(', ');
        }
        html += '<li><span class="error-path">' + escapeHtml(path) + '</span> &mdash; ' + escapeHtml(msg) + '</li>';
      }
      html += '</ul>';
      result.innerHTML = html;
    }
  }

  function runXmlValidation(text) {
    const parseResult = parseXml(text);

    if (!parseResult.valid) {
      result.className = 'result-invalid';
      let html = '<strong>XML Parse Error:</strong><ul class="error-list">';
      for (const err of parseResult.errors) {
        html += '<li>';
        if (err.line) {
          html += '<span class="error-path">Line ' + err.line;
          if (err.col) html += ', Column ' + err.col;
          html += '</span> &mdash; ';
        }
        html += escapeHtml(err.message);
        html += '</li>';
      }
      html += '</ul>';
      result.innerHTML = html;
      return;
    }

    const structErrors = validateXmlStructure(parseResult.doc);

    if (structErrors.length === 0) {
      result.className = 'result-valid';
      result.innerHTML = '<strong>Valid PsoftQL XML syntax.</strong>' + renderStructuredOutput(extractDataFromXml(parseResult.doc));
    } else {
      result.className = 'result-invalid';
      let html = '<strong>XML Structure Errors:</strong><ul class="error-list">';
      for (const err of structErrors) {
        html += '<li><span class="error-path">' + escapeHtml(err.path) + '</span> &mdash; ' + escapeHtml(err.message) + '</li>';
      }
      html += '</ul>';
      result.innerHTML = html;
    }
  }

  // ── Toolbar: Format ──

  document.getElementById('btn-format').addEventListener('click', function() {
    const text = input.value.trim();
    if (!text) return;

    if (detectFormat(text) === 'xml') {
      const parseResult = parseXml(text);
      if (!parseResult.valid) { runValidation(); return; }
      input.value = formatXmlFromDoc(parseResult.doc);
    } else {
      const parseResult = parseJsonWithDiagnostics(text);
      if (!parseResult.valid) { runValidation(); return; }
      input.value = JSON.stringify(parseResult.data, null, 2);
    }
    updateLineNumbers();
    runValidation();
  });

  // ── Toolbar: Compact ──

  document.getElementById('btn-compact').addEventListener('click', function() {
    const text = input.value.trim();
    if (!text) return;

    if (detectFormat(text) === 'xml') {
      const parseResult = parseXml(text);
      if (!parseResult.valid) { runValidation(); return; }
      input.value = compactXml(parseResult.doc);
    } else {
      const parseResult = parseJsonWithDiagnostics(text);
      if (!parseResult.valid) { runValidation(); return; }
      input.value = JSON.stringify(parseResult.data);
    }
    updateLineNumbers();
    runValidation();
  });

  // ── Toolbar: Clear ──

  document.getElementById('btn-clear').addEventListener('click', function() {
    input.value = '';
    currentFormat = null;
    updateLineNumbers();
    updateFormatIndicator(null);
    updateToolbarForFormat('json');
    result.className = 'result-empty';
    result.innerHTML = 'Paste PsoftQL JSON or XML above to validate.';
  });

  // ── Toolbar: Fill All Properties (JSON only) ──

  const skipOnFill = new Set(['effectiveDateOverride', 'aggregateConfig']);

  function defaultForType(propSchema) {
    if (propSchema.type === 'boolean') return false;
    if (propSchema.type === 'integer') return 0;
    if (propSchema.type === 'string') return '';
    if (propSchema.type === 'array') return [];
    if (propSchema.type === 'object') return {};
    return null;
  }

  function fillAllProperties(data) {
    const rootProps = schema.properties;
    for (const key of Object.keys(rootProps)) {
      if (key === 'records') continue;
      if (skipOnFill.has(key)) continue;
      if (!(key in data)) {
        data[key] = defaultForType(rootProps[key]);
      }
    }
    if (!data.records) data.records = [];
    const recordProps = schema.properties.records.items.properties;
    for (const rec of data.records) {
      for (const key of Object.keys(recordProps)) {
        if (skipOnFill.has(key)) continue;
        if (!(key in rec)) {
          rec[key] = defaultForType(recordProps[key]);
        }
      }
    }
    return data;
  }

  document.getElementById('btn-fill').addEventListener('click', function() {
    if (currentFormat === 'xml') return;

    let data;
    const text = input.value.trim();
    if (!text) {
      data = { records: [{ recordName: '' }] };
    } else {
      const parseResult = parseJsonWithDiagnostics(text);
      if (!parseResult.valid) { runValidation(); return; }
      data = parseResult.data;
    }
    fillAllProperties(data);
    input.value = JSON.stringify(data, null, 2);
    updateLineNumbers();
    runValidation();
  });

  // ── Toolbar: Add Record ──

  document.getElementById('btn-add-record').addEventListener('click', function() {
    const recordName = document.getElementById('add-record-name').value.trim();
    if (!recordName) {
      result.className = 'result-invalid';
      result.innerHTML = '<strong>Error:</strong> Record Name is required.';
      return;
    }
    const parentName = document.getElementById('add-parent-name').value.trim();
    const text = input.value.trim();
    const format = text ? detectFormat(text) : 'json';

    if (format === 'xml') {
      const newText = addXmlRecord(text, recordName, parentName);
      if (newText === null) {
        result.className = 'result-invalid';
        result.innerHTML = '<strong>Error:</strong> Could not find &lt;/records&gt; in the XML. Ensure your XML contains a &lt;records&gt; element.';
        return;
      }
      input.value = newText;
    } else {
      let data;
      if (!text) {
        data = { records: [] };
      } else {
        const parseResult = parseJsonWithDiagnostics(text);
        if (!parseResult.valid) { runValidation(); return; }
        data = parseResult.data;
      }
      if (!data.records) data.records = [];

      const newRecord = { recordName: recordName };
      if (parentName) {
        newRecord.parentRecordName = parentName;
      }
      data.records.push(newRecord);
      input.value = JSON.stringify(data, null, 2);
    }

    updateLineNumbers();
    runValidation();

    document.getElementById('add-record-name').value = '';
    document.getElementById('add-parent-name').value = '';
  });

  // ── Request Builder ──

  const builderSection = document.getElementById('builder-section');
  const builderToggle = document.getElementById('builder-toggle');
  const builderError = document.getElementById('builder-error');
  const btnBuilderAdd = document.getElementById('btn-builder-add');
  const btnBuilderReset = document.getElementById('btn-builder-reset');

  builderToggle.addEventListener('click', function() {
    builderSection.classList.toggle('collapsed');
  });

  function showBuilderError(msg) {
    builderError.textContent = msg;
    builderError.classList.add('visible');
  }

  function clearBuilderError() {
    builderError.textContent = '';
    builderError.classList.remove('visible');
  }

  // Repeater row schemas: defines inputs per array type.
  const REPEATER_SCHEMAS = {
    criteriaFields: [
      { key: 'fieldName', type: 'text', placeholder: 'fieldName' },
      { key: 'operator', type: 'select', options: ['=', '<>', '!=', '<', '<=', '>', '>=', 'LIKE', 'IN'] },
      { key: 'fieldValue', type: 'text', placeholder: 'fieldValue' }
    ],
    joinFields: [
      { key: 'parentField', type: 'text', placeholder: 'parentField' },
      { key: 'childField', type: 'text', placeholder: 'childField' }
    ],
    orderByFields: [
      { key: 'fieldName', type: 'text', placeholder: 'fieldName' },
      { key: 'sortOrder', type: 'select', options: ['ASC', 'DESC'] }
    ],
    excludeFields: [
      { key: '__value', type: 'text', placeholder: 'fieldName' }
    ],
    includeDescriptionsFor: [
      { key: '__value', type: 'text', placeholder: 'fieldName' }
    ]
  };

  function buildRow(arrayName) {
    const schema = REPEATER_SCHEMAS[arrayName];
    const row = document.createElement('div');
    row.className = 'builder-array-row';
    schema.forEach(function(field) {
      let el;
      if (field.type === 'select') {
        el = document.createElement('select');
        field.options.forEach(function(opt) {
          const o = document.createElement('option');
          o.value = opt;
          o.textContent = opt;
          el.appendChild(o);
        });
      } else {
        el = document.createElement('input');
        el.type = 'text';
        el.placeholder = field.placeholder || '';
      }
      el.dataset.key = field.key;
      row.appendChild(el);
    });
    const removeBtn = document.createElement('button');
    removeBtn.type = 'button';
    removeBtn.className = 'row-remove';
    removeBtn.title = 'Remove this row';
    removeBtn.textContent = '×';
    row.appendChild(removeBtn);
    return row;
  }

  // Wire +Add and ×remove buttons via delegation on each .builder-array container.
  document.querySelectorAll('.builder-array').forEach(function(container) {
    const arrayName = container.dataset.array;
    const rowsContainer = container.querySelector('.builder-array-rows');
    container.querySelector('.builder-array-add').addEventListener('click', function() {
      rowsContainer.appendChild(buildRow(arrayName));
    });
    rowsContainer.addEventListener('click', function(e) {
      if (e.target.classList.contains('row-remove')) {
        e.target.closest('.builder-array-row').remove();
      }
    });
  });

  function collectRepeater(arrayName) {
    const container = document.querySelector('.builder-array[data-array="' + arrayName + '"]');
    const rows = container.querySelectorAll('.builder-array-row');
    const schema = REPEATER_SCHEMAS[arrayName];
    const isScalar = schema.length === 1 && schema[0].key === '__value';
    const out = [];
    rows.forEach(function(row) {
      const values = {};
      let anyNonEmpty = false;
      schema.forEach(function(field) {
        const el = row.querySelector('[data-key="' + field.key + '"]');
        const val = el ? (el.value || '').trim() : '';
        values[field.key] = val;
        if (val !== '') anyNonEmpty = true;
      });
      if (!anyNonEmpty) return;
      if (isScalar) {
        out.push(values.__value);
      } else {
        out.push(values);
      }
    });
    return out;
  }

  function buildRecordObject() {
    const recordName = document.getElementById('b-recordName').value.trim();
    if (!recordName) {
      return { error: 'Record Name is required.' };
    }
    const record = { recordName: recordName };

    const parentRecordName = document.getElementById('b-parentRecordName').value.trim();
    if (parentRecordName) record.parentRecordName = parentRecordName;

    const sqlWhereClause = document.getElementById('b-sqlWhereClause').value.trim();
    if (sqlWhereClause) record.sqlWhereClause = sqlWhereClause;

    if (document.getElementById('b-useParentEffectiveDate').checked) record.useParentEffectiveDate = true;
    if (document.getElementById('b-useNonKeyFieldsInJoin').checked) record.useNonKeyFieldsInJoin = true;
    if (document.getElementById('b-doNotAutoJoinToParent').checked) record.doNotAutoJoinToParent = true;

    ['criteriaFields', 'joinFields', 'orderByFields', 'excludeFields', 'includeDescriptionsFor'].forEach(function(name) {
      const arr = collectRepeater(name);
      if (arr.length > 0) record[name] = arr;
    });

    return { record: record };
  }

  btnBuilderAdd.addEventListener('click', function() {
    clearBuilderError();

    if (currentFormat === 'xml') {
      showBuilderError('Request Builder supports JSON only. Switch the editor to JSON to use the builder.');
      return;
    }

    const built = buildRecordObject();
    if (built.error) {
      showBuilderError(built.error);
      return;
    }

    const text = input.value.trim();
    let data;
    if (!text) {
      data = { records: [] };
    } else {
      const parseResult = parseJsonWithDiagnostics(text);
      if (!parseResult.valid) {
        showBuilderError('Editor contains invalid JSON. Fix the JSON errors below before adding a new record.');
        runValidation();
        return;
      }
      data = parseResult.data;
      if (typeof data !== 'object' || data === null || Array.isArray(data)) {
        showBuilderError('Editor JSON must be an object with a records array.');
        return;
      }
    }
    if (!Array.isArray(data.records)) data.records = [];
    data.records.push(built.record);

    input.value = JSON.stringify(data, null, 2);
    updateLineNumbers();
    runValidation();
  });

  btnBuilderReset.addEventListener('click', function() {
    clearBuilderError();
    document.getElementById('b-recordName').value = '';
    document.getElementById('b-parentRecordName').value = '';
    document.getElementById('b-sqlWhereClause').value = '';
    document.getElementById('b-useParentEffectiveDate').checked = false;
    document.getElementById('b-useNonKeyFieldsInJoin').checked = false;
    document.getElementById('b-doNotAutoJoinToParent').checked = false;
    document.querySelectorAll('.builder-array-rows').forEach(function(rc) {
      rc.innerHTML = '';
    });
  });

})();
</script>
