/* DJB OpenAPI Editor POC — app.js
 * vanilla JS, 단일 상태 + 함수형 렌더
 */
(function () {
  'use strict';

  // ============================================================
  // 1. 상태
  // ============================================================
  const STEPS = [
    { id: 1, title: '기본정보',       hint: 'API 메타 정보·엔드포인트·인증/보안을 확인·입력합니다.' },
    { id: 2, title: '요청 정보',      hint: '요청 Header/Parameter/Body 와 API 설명(HTML)을 작성합니다.' },
    { id: 3, title: '응답 정보',      hint: '응답 Header/Body 와 API 설명(HTML)을 작성합니다.' },
    { id: 4, title: '요청/응답 샘플',  hint: '요청/응답 예제를 입력합니다.' },
    { id: 5, title: '포탈 게시 정보',   hint: '개발자포탈 공개 설정(공개 여부/권한/법인/응답유형)을 지정합니다.' }
  ];

  const state = {
    step: 1,
    showPreview: true,
    previewTab: 'swagger',         // 'swagger' | 'yaml-ro' | 'yaml-edit'
    data: deepClone(window.SAMPLE_DATA),
    step3Section: 'body',           // 'params' | 'body' | 'response' | 'components'
    responseStatusTab: '200',
    components: [],                 // 재사용 스키마(POC: 빈 상태)
  };

  // GW authtype(oauth/api_key 등, 서버 DETAIL_SPEC 응답). 인증 badge 라벨 판별용.
  // (enricher 는 oauth·api_key 를 모두 apiKey-in-header 로 모델링하므로 scheme.type 만으로 구분 불가)
  var djbAuthType = '';

  // ============================================================
  // 2. 유틸
  // ============================================================
  function deepClone(o) { return JSON.parse(JSON.stringify(o)); }
  function $(sel, ctx) { return (ctx || document).querySelector(sel); }
  function $$(sel, ctx) { return Array.from((ctx || document).querySelectorAll(sel)); }
  function el(tag, attrs, children) {
    const e = document.createElement(tag);
    if (attrs) for (const k in attrs) {
      if (k === 'class') e.className = attrs[k];
      else if (k === 'html') e.innerHTML = attrs[k];
      else if (k.startsWith('on') && typeof attrs[k] === 'function') e.addEventListener(k.slice(2), attrs[k]);
      else e.setAttribute(k, attrs[k]);
    }
    if (children) (Array.isArray(children) ? children : [children]).forEach(c => {
      if (c == null) return;
      e.appendChild(typeof c === 'string' ? document.createTextNode(c) : c);
    });
    return e;
  }
  function debounce(fn, ms) { let t; return function () { clearTimeout(t); t = setTimeout(() => fn.apply(this, arguments), ms); }; }
  function toast(msg, kind) {
    const t = $('#toast');
    t.textContent = msg;
    t.className = 'fixed bottom-5 left-1/2 -translate-x-1/2 px-4 py-2 rounded text-white text-sm shadow-lg z-50 ' +
      (kind === 'error' ? 'bg-red-600' : kind === 'warn' ? 'bg-amber-600' : 'bg-slate-900');
    t.classList.remove('hidden');
    setTimeout(() => t.classList.add('hidden'), 2400);
  }
  function escapeHtml(s) {
    return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
  }
  // CrossScriptingFilter 가 엔티티(&#40; 등)로 치환한 값 복원. input value 로만 사용 → 안전, 멱등.
  function decodeEntities(s) { if (s == null || s === '') return s; var t = document.createElement('textarea'); t.innerHTML = String(s); return t.value; }

  // 잠금 필드 표준 렌더
  function lockedInput(value, opts) {
    opts = opts || {};
    const cls = 'w-full px-3 py-1.5 pr-8 text-sm rounded border border-slate-200 bg-slate-100 text-slate-600 cursor-not-allowed';
    return `
      <div class="relative" title="API 게이트웨이에서 자동 동기화된 값입니다">
        <input type="text" value="${escapeHtml(value)}" readonly tabindex="-1" class="${cls} ${opts.extra || ''}" />
        <span class="absolute right-2 top-1/2 -translate-y-1/2 text-slate-400 text-xs">🔒</span>
      </div>`;
  }
  function input(value, dataAttr, opts) {
    opts = opts || {};
    const cls = 'w-full px-3 py-1.5 text-sm rounded border border-slate-300 bg-white text-slate-800 focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-brand-500';
    const type = opts.type || 'text';
    return `<input type="${type}" value="${escapeHtml(value)}" ${dataAttr} class="${cls} ${opts.extra || ''}" placeholder="${escapeHtml(opts.placeholder || '')}" />`;
  }
  function textarea(value, dataAttr, opts) {
    opts = opts || {};
    return `<textarea ${dataAttr} rows="${opts.rows || 3}"
              class="w-full px-3 py-2 text-sm rounded border border-slate-300 bg-white focus:outline-none focus:ring-2 focus:ring-brand-500"
              placeholder="${escapeHtml(opts.placeholder || '')}">${escapeHtml(value)}</textarea>`;
  }
  function selectInput(value, options, dataAttr, opts) {
    opts = opts || {};
    const optHtml = options.map(o => {
      const v = typeof o === 'string' ? o : o.value;
      const l = typeof o === 'string' ? o : o.label;
      return `<option value="${escapeHtml(v)}" ${v === value ? 'selected' : ''}>${escapeHtml(l)}</option>`;
    }).join('');
    const cls = 'w-full px-2 py-1.5 text-sm rounded border border-slate-300 bg-white focus:outline-none focus:ring-2 focus:ring-brand-500';
    const locked = opts.locked ? 'disabled' : '';
    const lockedCls = opts.locked ? 'bg-slate-100 text-slate-600 cursor-not-allowed' : '';
    return `<select ${dataAttr} ${locked} class="${cls} ${lockedCls} ${opts.extra || ''}">${optHtml}</select>`;
  }
  function checkbox(checked, dataAttr, opts) {
    opts = opts || {};
    const locked = opts.locked ? 'disabled' : '';
    return `<input type="checkbox" ${checked ? 'checked' : ''} ${dataAttr} ${locked}
              class="w-4 h-4 rounded border-slate-300 text-brand-600 focus:ring-brand-500 ${opts.locked ? 'cursor-not-allowed' : ''}" />`;
  }

  // ============================================================
  // 3. OpenAPI 스펙 빌더 (state.data → OpenAPI 3.1 객체)
  // ============================================================
  function buildOpenApiSpec() {
    const d = state.data;
    syncResponseExamplesFromSchema();   // 응답 예제 = 응답 정보(스키마·예제값) 파생 — 저장/미리보기 일관
    const spec = {
      openapi: (d.__openapi || '3.1.0'),
      info: {
        title: d.info.title.value,
        version: d.info.version.value
      },
      paths: {},
      components: { securitySchemes: {} },
      tags: d.tags.map(t => ({ name: t.name, description: t.description }))
    };

    // 설명(info.description)은 API 소개 페이지 전용 → spec 에는 미포함(저장은 description 컬럼에 별도).
    if (d.info.termsOfService && d.info.termsOfService.value) spec.info.termsOfService = d.info.termsOfService.value;
    const contact = {};
    if (d.info.contact.name.value)  contact.name  = d.info.contact.name.value;
    if (d.info.contact.email.value) contact.email = d.info.contact.email.value;
    if (d.info.contact.url.value)   contact.url   = d.info.contact.url.value;
    if (Object.keys(contact).length) spec.info.contact = contact;
    const license = {};
    if (d.info.license.name.value) license.name = d.info.license.name.value;
    if (d.info.license.url.value)  license.url  = d.info.license.url.value;
    if (license.name) spec.info.license = license;

    if (d.externalDocs && d.externalDocs.url.value) {
      spec.externalDocs = { url: d.externalDocs.url.value };
      if (d.externalDocs.description.value) spec.externalDocs.description = d.externalDocs.description.value;
    }

    // 경로 + 오퍼레이션
    const op = d.operation;
    var _ifId = (window.DJB_CTX && window.DJB_CTX.eaiSvcName) || op.operationId.value;
    const operation = {
      tags: (d.tags || []).map(t => t.name),   // 오퍼레이션 태그 = 최상위 tags(단일 소스) → tags 삭제 시 Swagger 그룹도 사라짐
      summary: (d.info.title && d.info.title.value) || op.summary.value || undefined,
      description: op.description.value || undefined,
      operationId: _ifId,
      deprecated: op.deprecated.value || undefined,
      parameters: d.parameters.map(p => ({
        in: p.in,
        name: p.name.value,
        required: !!p.required.value,
        description: p.description.value || undefined,
        schema: schemaRowToSchema(p),
        example: p.example.value || undefined
      })),
      requestBody: {
        required: !!d.requestBody.required.value,
        content: {
          [d.requestBody.mediaType]: {
            schema: schemaArrayToObject(d.requestBody.schema),
            example: tryParseJson(d.examples.request[d.requestBody.mediaType].default)
          }
        }
      },
      responses: {}
    };

    Object.keys(d.responses).forEach(code => {
      const r = d.responses[code];
      const ex = (d.examples.response && d.examples.response[code]) || { body: '', headers: [] };
      const content = {};
      if (r.schema && r.schema.length) content['application/json'] = { schema: schemaArrayToObject(r.schema) };
      if (ex.body) {
        content['application/json'] = content['application/json'] || {};
        content['application/json'].example = tryParseJson(ex.body);
      }
      operation.responses[code] = {
        description: r.description.value || '',
        ...(Object.keys(content).length ? { content } : {})
      };
      // 상태코드별 응답 헤더 세트
      if (ex.headers && ex.headers.length) {
        operation.responses[code].headers = {};
        ex.headers.forEach(h => {
          if (!h.name) return;
          operation.responses[code].headers[h.name] = { description: h.description, schema: { type: h.type }, example: h.example };
        });
      }
    });

    // 경로(op.path.value)에는 어댑터경로가 이미 baking 됨(백엔드 applyAutoRules). 서버에는 응답유형별 호스트만 붙인다.
    //  - gw   : djb.gateway.base-url (PortalProperty)  → [gw호스트 + 어댑터경로 + 경로]
    //  - mock : Mock Server URL(전체 URL)             → [mock URL 그대로]  (어댑터/오퍼레이션 경로 미부착)
    //  - 그 외 : 호스트 없음                           → [어댑터경로 + 경로]
    var _rt = (d.portal && d.portal.responseType) || 'sample';
    var _host;
    var _pathKey = op.path.value;
    if (_rt === 'gw') {
      _host = (window.DJB_CTX && window.DJB_CTX.gwAddress) || 'http://127.0.0.1:39310';
    } else if (_rt === 'mock') {
      // Mock 은 전체 호출 URL. origin=서버, pathname(+query)=경로로 분해해 경로가 뒤에 덧붙지 않도록 한다.
      var _mk = splitFullUrl((d.portal && d.portal.mockUrl) || '');
      _host = _mk.origin;
      _pathKey = _mk.path;
    } else {
      _host = '';
    }
    if (_host) spec.servers = [{ url: _host }];
    spec.paths[_pathKey] = { [op.method.value.toLowerCase()]: operation };

    // 보안 스킴
    d.securitySchemes.forEach(s => {
      const ss = { type: s.type };
      if (s.description && s.description.value) ss.description = s.description.value;
      if (s.type === 'apiKey') { ss.name = s.name.value; ss.in = s.in.value; }
      if (s.type === 'http')   { ss.scheme = s.scheme.value; if (s.bearerFormat && s.bearerFormat.value) ss.bearerFormat = s.bearerFormat.value; }
      if (s.type === 'oauth2') { ss.flows = s.flows || {}; }
      if (s.type === 'openIdConnect') { ss.openIdConnectUrl = s.openIdConnectUrl.value; }
      spec.components.securitySchemes[s.key] = ss;
    });

    if (d.globalSecurity && d.globalSecurity.length) {
      spec.security = d.globalSecurity.map(k => ({ [k]: [] }));
    }

    // (태그명=API타이틀 강제 override 제거) — tags/operation.tags 는 state.data 값을 그대로 사용해
    // 스펙 편집에서 변경한 tags 가 원복되지 않도록 함. 자동생성 시 태그=API명 은 백엔드(generateSpec)에서 적용.

    return spec;
  }
  function tryParseJson(s) { try { return JSON.parse(s); } catch (e) { return s; } }

  function schemaRowToSchema(row) {
    const s = { type: row.type.value };
    if (row.format && row.format.value) s.format = row.format.value;
    if (row.maxLength && row.maxLength.value) s.maxLength = Number(row.maxLength.value);
    if (row.pattern && row.pattern.value) s.pattern = row.pattern.value;
    if (row.description && row.description.value) s.description = row.description.value;
    if (row.example && row.example.value !== '') s.example = coerceExample(row.type.value, row.example.value);
    if (row.enumStr && row.enumStr.value) s.enum = row.enumStr.value.split(',').map(v => v.trim()).filter(Boolean);
    if (row.name && row.name.locked) s['x-djb-gw'] = true;   // GW 잠금 마커 보존(저장→로드 시 잠금 유지)
    return s;
  }
  function coerceExample(type, v) {
    if (type === 'integer' || type === 'number') { const n = Number(v); return isNaN(n) ? v : n; }
    if (type === 'boolean') return v === 'true' || v === true;
    return v;
  }
  function schemaArrayToObject(arr) {
    if (!arr || !arr.length) return { type: 'object' };
    const obj = { type: 'object', properties: {}, required: [] };
    arr.forEach(row => {
      const name = row.name.value;
      if (!name) return;
      if (row.type.value === 'object' && row.children) {
        obj.properties[name] = schemaArrayToObject(row.children);
        if (row.description && row.description.value) obj.properties[name].description = row.description.value;
        if (row.name && row.name.locked) obj.properties[name]['x-djb-gw'] = true;
      } else if (row.type.value === 'array') {
        const itemType = (row.itemsType && row.itemsType.value) || 'string';
        obj.properties[name] = {
          type: 'array',
          // array<object>(GW GRID): 자식 행을 items.properties 로 직렬화. (누락 시 저장 왕복에서 자식 유실)
          items: itemType === 'object' ? schemaArrayToObject(row.children || []) : { type: itemType }
        };
        if (row.description && row.description.value) obj.properties[name].description = row.description.value;
        if (row.name && row.name.locked) obj.properties[name]['x-djb-gw'] = true;
      } else {
        obj.properties[name] = schemaRowToSchema(row);
      }
      if (row.required && row.required.value) obj.required.push(name);
    });
    if (!obj.required.length) delete obj.required;
    return obj;
  }

  // ============================================================
  // 4. 스텝퍼 렌더
  // ============================================================
  function renderStepper() {
    const ol = $('#stepper');
    ol.innerHTML = '';
    STEPS.forEach(s => {
      const isActive = state.step === s.id;
      const isDone = state.step > s.id;
      const li = el('li', {
        'class': 'step-item flex items-center gap-3 py-3 px-3 cursor-pointer select-none',
        'data-step': s.id
      });
      const circle = el('span', {
        'class': 'flex items-center justify-center w-7 h-7 rounded-full text-xs font-semibold ' +
          (isActive ? 'bg-brand-600 text-white' :
            isDone ? 'bg-brand-100 text-brand-700 border border-brand-300' :
              'bg-slate-100 text-slate-500 border border-slate-200')
      }, isDone ? '✓' : String(s.id));
      const label = el('div', {}, [
        el('div', { 'class': 'text-sm font-medium ' + (isActive ? 'text-slate-900' : 'text-slate-600') }, s.title),
        el('div', { 'class': 'text-[11px] text-slate-400' }, isActive ? '진행중' : (isDone ? '완료' : '대기'))
      ]);
      li.appendChild(circle); li.appendChild(label);
      li.addEventListener('click', () => goStep(s.id));
      ol.appendChild(li);
    });
    $('#stepper-progress').style.width = ((state.step / STEPS.length) * 100).toFixed(2) + '%';
    $('#form-step-num').textContent = String(state.step);
    $('#form-step-title').textContent = STEPS[state.step - 1].title;
    $('#footer-hint').textContent = STEPS[state.step - 1].hint;
    $('#btn-prev').disabled = state.step === 1;
    $('#btn-next').textContent = state.step === STEPS.length ? '완료' : '다음 ▶';
  }

  function goStep(n) {
    if (n < 1 || n > STEPS.length) return;
    state.step = n;
    if (n === 2) state.reqInfoTab = firstReqTabWithData();  // 요청 정보: 값 있는 탭(Header→Parameter→Body 순) 먼저
    if (n === 3) state.resInfoTab = 'body';
    renderStepper();
    renderForm();
    if (state.step === 2 || state.step === 3) {
      setPreviewVisibility(false);          // 요청/응답 정보(넓은 테이블): 우측 미리보기 패널 숨김
    } else {
      setPreviewVisibility(true);
      restoreNormalPreviewLayout();
    }
  }

  // ============================================================
  // 5. Form 렌더 디스패치
  // ============================================================
  function renderForm() {
    const root = $('#form-content');
    root.innerHTML = '';
    switch (state.step) {
      case 1: renderStep1(root); break;    // 기본정보(+엔드포인트+인증/보안)
      case 2: renderReqInfo(root); break;  // 요청 정보 (Header/Parameter/Body/API 설명)
      case 3: renderResInfo(root); break;  // 응답 정보 (Header/Body/API 설명)
      case 4: renderStep5(root); break;    // 요청/응답 샘플
      case 5: renderStep6(root); break;    // 포탈 게시 정보
    }
  }

  // ----- helpers for section blocks -----
  function sectionTitle(title, hint) {
    return `<div class="mb-2"><h3 class="text-sm font-semibold text-slate-800">${escapeHtml(title)}</h3>${
      hint ? `<p class="text-xs text-slate-500 mt-0.5">${escapeHtml(hint)}</p>` : ''}</div>`;
  }
  function fieldRow(label, controlHtml, opts) {
    opts = opts || {};
    return `
      <div class="grid grid-cols-12 items-start gap-3 mb-3">
        <label class="col-span-3 pt-1.5 text-sm text-slate-600 ${opts.required ? 'font-medium' : ''}">
          ${escapeHtml(label)}${opts.required ? ' <span class="text-red-500">*</span>' : ''}
          ${opts.locked ? '<span class="ml-1 text-slate-400 text-xs">🔒</span>' : ''}
        </label>
        <div class="col-span-9">${controlHtml}${opts.hint ? `<p class="text-xs text-slate-400 mt-1">${escapeHtml(opts.hint)}</p>` : ''}</div>
      </div>`;
  }
  function card(html) {
    return `<div class="border border-slate-200 rounded-lg bg-white p-4 mb-4">${html}</div>`;
  }

  // ============================================================
  // 6. Step 1 — 기본정보
  // ============================================================
  function renderStep1(root) {
    const i = state.data.info;
    const op = state.data.operation;
    const methodColor = {
      GET: 'bg-emerald-100 text-emerald-700 border-emerald-300',
      POST: 'bg-blue-100 text-blue-700 border-blue-300',
      PUT: 'bg-amber-100 text-amber-700 border-amber-300',
      DELETE: 'bg-red-100 text-red-700 border-red-300'
    }[op.method.value] || 'bg-slate-100 text-slate-700 border-slate-300';
    root.innerHTML = `
      ${card(
        sectionTitle('API 정보', '게이트웨이가 제공한 식별 정보와 사용자가 작성하는 설명을 분리해 표시합니다.') +
        `<div class="flex items-center gap-3 mb-4 p-3 bg-slate-50 border border-slate-200 rounded">
           <span class="px-2.5 py-1 text-xs font-bold rounded border ${methodColor}">${escapeHtml(op.method.value)}</span>
           <code class="text-sm font-mono text-slate-800">${escapeHtml(op.path.value)}</code>
           <span class="text-xs text-slate-400 ml-auto">🔒 게이트웨이에서 자동 동기화</span>
         </div>` +
        fieldRow('Operation ID', lockedInput((window.DJB_CTX && window.DJB_CTX.eaiSvcName) || op.operationId.value), { locked: true }) +
        fieldRow('API 타이틀', input(i.title.value, 'data-path="info.title.value"', { required: true }), { required: true }) +
        fieldRow('버전',       input(i.version.value, 'data-path="info.version.value"', { required: true }), { required: true }) +
        fieldRow('간단 설명',  input((i.summary && i.summary.value) || '', 'data-path="info.summary.value"', { required: true, placeholder: '태그 설명으로 사용됩니다' }), { required: true }) +
        fieldRow('상세 설명',  textarea(op.description.value, 'data-path="operation.description.value"', { rows: 4, placeholder: '동작, 비즈니스 규칙, 주의사항' }) + '<div class="text-xs text-slate-400 mt-1">Markdown 문법 사용 가능</div>')
      )}

      ${card(
        sectionTitle('외부 문서 링크') +
        fieldRow('설명', input(state.data.externalDocs.description.value, 'data-path="externalDocs.description.value"')) +
        fieldRow('URL',  input(state.data.externalDocs.url.value,         'data-path="externalDocs.url.value"', { placeholder: 'https://...' }))
      )}

      ${renderAuthSection()}
    `;
  }

  // 설명 = Summernote 리치 에디터(#info-desc-editor). Step6(포탈 게시 정보)에서 렌더 후 호출.
  function initInfoDescEditor() {
    if (!(window.jQuery && window.jQuery.fn && window.jQuery.fn.summernote)) return;
    var $desc = window.jQuery('#info-desc-editor');
    if (!$desc.length) return;
    try { $desc.summernote('destroy'); } catch (e) {}
    var koOk = window.jQuery.summernote && window.jQuery.summernote.lang && window.jQuery.summernote.lang['ko-KR'];
    $desc.summernote({
      height: 360,
      lang: koOk ? 'ko-KR' : 'en-US',
      placeholder: '여기에 내용을 입력하세요.',
      toolbar: DJB_SN_TOOLBAR,
      callbacks: {
        onInit: addEditorContentClass,
        onChange: function (contents) { state.data.info.description.value = contents; }
      }
    });
    $desc.summernote('code', state.data.info.description.value || '');
    addEditorContentClass();
  }
  function renderTagChips() {
    if (!state.data.tags.length) return '<span class="text-xs text-slate-400">등록된 태그가 없습니다</span>';
    return state.data.tags.map((t, i) =>
      `<span class="inline-flex items-center gap-1 px-2 py-1 text-xs rounded-full bg-brand-50 text-brand-700 border border-brand-200">
         ${escapeHtml(t.name)}${t.description ? ' · ' + escapeHtml(t.description) : ''}
         <button data-remove-tag="${i}" class="text-brand-500 hover:text-brand-800 ml-1">✕</button>
       </span>`
    ).join('');
  }

  // (구 Step 2 엔드포인트는 기본정보 카드로 병합됨)
  function renderOpTagSelector(selected) {
    return `<div class="flex flex-wrap gap-2">
      ${state.data.tags.map(t => `
        <label class="inline-flex items-center gap-1 px-2 py-1 text-xs rounded-full border cursor-pointer
                       ${selected.includes(t.name) ? 'bg-brand-50 border-brand-300 text-brand-700' : 'bg-white border-slate-200 text-slate-600'}">
          <input type="checkbox" data-op-tag="${escapeHtml(t.name)}" ${selected.includes(t.name) ? 'checked' : ''} class="w-3 h-3" />
          ${escapeHtml(t.name)}
        </label>
      `).join('')}
      ${state.data.tags.length === 0 ? '<span class="text-xs text-slate-400">먼저 Step 1 에서 태그를 등록하세요</span>' : ''}
    </div>`;
  }

  // ============================================================
  // 8. Step 3 — 파라미터 / 스키마 (핵심)
  // ============================================================
  // 서브 탭 바 (요청/응답 정보 공용)
  function subTabBar(tabs, cur, attr) {
    return '<div class="flex items-center gap-1 border-b border-slate-200 mb-4">'
      + tabs.map(t => `<button ${attr}="${t.key}" class="px-4 py-2 text-sm rounded-t border-b-2 ${cur === t.key ? 'border-brand-600 text-brand-700 font-medium' : 'border-transparent text-slate-600'}">${t.label}</button>`).join('')
      + '</div>';
  }

  // 요청 정보 진입 시 먼저 보일 서브탭 = 값이 든 첫 탭(Header→Parameter→Body 순).
  // 보통 GET 은 Parameter(query), POST 는 Body 에만 값이 있음. 전부 비면 Body 로.
  function firstReqTabWithData() {
    var params = (state.data && state.data.parameters) || [];
    var hasHeader = params.some(function (p) { return p.in === 'header'; });
    var hasParam = params.some(function (p) { return p.in === 'query'; });
    var hasBody = !!(state.data && state.data.requestBody && state.data.requestBody.schema && state.data.requestBody.schema.length);
    if (hasHeader) return 'header';
    if (hasParam) return 'param';
    if (hasBody) return 'body';
    return 'body';
  }

  // Step 2 — 요청 정보 (Header / Parameter / Body / API 설명(HTML))
  function renderReqInfo(root) {
    const tab = state.reqInfoTab || 'body';
    const tabs = [
      { key: 'header', label: 'Header' },
      { key: 'param', label: 'Parameter' },
      { key: 'body', label: 'Body' },
      { key: 'desc', label: 'API 설명(HTML)' }
    ];
    let body;
    if (tab === 'header') body = renderParamsTable('header');
    else if (tab === 'param') body = renderParamsTable('param');
    else if (tab === 'body') body = renderBodyTable();
    else body = card(
      sectionTitle('요청 API 설명(HTML)', 'API 소개(설명) 페이지에 표시되는 자료입니다.') +
      `<div style="min-width:890px;max-width:900px"><div class="mb-2 flex justify-end"><button id="btn-regen-req-spec" class="px-2 py-1 text-xs rounded border border-slate-300 hover:bg-slate-50">스키마로 재생성</button></div>` +
      '<div id="msg-editor"></div></div>'
    );
    root.innerHTML = subTabBar(tabs, tab, 'data-reqinfo-tab') + body;
    if (tab === 'desc') {
      var fc = document.getElementById('form-content');
      // 카드/탭바/툴바/여백 오버헤드를 제외해 폼 영역에 세로스크롤이 생기지 않는 높이로
      var h = Math.max(300, (fc ? fc.clientHeight : 600) - 290);
      initSpecEditor('#msg-editor', 'request', h);
    }
  }

  // Step 3 — 응답 정보 (Header / Body / API 설명(HTML))
  function renderResInfo(root) {
    const tab = state.resInfoTab || 'body';
    const tabs = [
      { key: 'header', label: 'Header' },
      { key: 'body', label: 'Body' },
      { key: 'desc', label: 'API 설명(HTML)' }
    ];
    let body;
    if (tab === 'header') body = renderResponseHeaderCard();
    else if (tab === 'body') body = renderResponseTable();
    else body = card(
      sectionTitle('응답 API 설명(HTML)', 'API 소개(설명) 페이지에 표시되는 자료입니다.') +
      `<div style="min-width:890px;max-width:900px"><div class="mb-2 flex justify-end"><button id="btn-regen-res-spec" class="px-2 py-1 text-xs rounded border border-slate-300 hover:bg-slate-50">스키마로 재생성</button></div>` +
      '<div id="msg-editor"></div></div>'
    );
    root.innerHTML = subTabBar(tabs, tab, 'data-resinfo-tab') + body;
    if (tab === 'desc') {
      var fc = document.getElementById('form-content');
      // 카드/탭바/툴바/여백 오버헤드를 제외해 폼 영역에 세로스크롤이 생기지 않는 높이로
      var h = Math.max(300, (fc ? fc.clientHeight : 600) - 290);
      initSpecEditor('#msg-editor', 'response', h);
    }
  }

  // 응답 Header 카드 — 상태코드별 응답 헤더(examples.response[code].headers)
  function renderResponseHeaderCard() {
    const codes = Object.keys(state.data.examples.response);
    const cur = state.responseStatusTab;
    const curSet = state.data.examples.response[cur] || { body: '', headers: [] };
    return card(
      sectionTitle('응답 Header', '상태코드별 응답 헤더를 정의합니다. 추가한 상태코드는 Body/[요청/응답 샘플]과 연동됩니다.') +
      `<div class="flex items-center gap-1 border-b border-slate-200 mb-3 flex-wrap">
        ${codes.map(code => `
          <button data-ex-res-tab="${code}" class="px-3 py-1.5 text-sm rounded-t border-b-2
            ${cur === code ? 'border-brand-600 text-brand-700 font-medium' : 'border-transparent text-slate-600'}">${escapeHtml(code)}${code !== '200' ? ` <span data-del-status="${code}" class="ml-1 text-red-400 hover:text-red-600" title="상태코드 삭제">✕</span>` : ''}</button>
        `).join('')}
        <button id="btn-add-status" class="ml-2 px-2 py-1 text-sm rounded border border-dashed border-slate-300 text-brand-600 hover:bg-brand-50">+ 상태코드</button>
      </div>` +
      `<table class="w-full text-sm">
        <thead class="bg-slate-50 text-xs text-slate-500 uppercase">
          <tr><th class="py-2 px-2 text-left">name</th><th class="py-2 px-2 text-left w-32">type</th><th class="py-2 px-2 text-left">description</th><th class="py-2 px-2 text-left w-32">example</th><th class="w-10"></th></tr>
        </thead>
        <tbody>
          ${(curSet.headers || []).map((h, i) => `
            <tr class="border-b border-slate-100">
              <td class="py-2 px-2">${input(h.name, `data-resp-header="${i}.name"`)}</td>
              <td class="py-2 px-2">${selectInput(h.type, ['string', 'integer', 'number', 'boolean'], `data-resp-header="${i}.type"`)}</td>
              <td class="py-2 px-2">${input(h.description, `data-resp-header="${i}.description"`)}</td>
              <td class="py-2 px-2">${input(h.example, `data-resp-header="${i}.example"`)}</td>
              <td class="py-2 px-2 text-center"><button data-del-header="${i}" class="text-red-500 hover:text-red-700">✕</button></td>
            </tr>
          `).join('')}
        </tbody>
      </table>
      <div class="mt-3 flex justify-end">
        <button id="btn-add-header" class="px-3 py-1.5 text-sm rounded border border-slate-300 hover:bg-slate-50">+ 헤더 추가</button>
      </div>`
    );
  }

  function renderStep3Section() {
    // add-row/자식추가/토글/탭전환 재렌더용 — 현재 스텝 폼 재렌더(활성 서브탭 유지)
    renderForm();
  }

  // --- Parameters 테이블 (kind: 'header' = in=header / 'param' = query 전용) ---
  function renderParamsTable(kind) {
    const isHeader = kind === 'header';
    const entries = state.data.parameters
      .map((p, i) => ({ p: p, i: i }))
      .filter(e => isHeader ? e.p.in === 'header' : e.p.in === 'query');
    const rows = entries.map(e => {
      const p = e.p, i = e.i;
      return `
      <tr class="border-b border-slate-100 ${p.name.locked ? 'locked-row' : ''}">
        <td class="py-2 px-2">${p.name.locked ? lockedInput(p.name.value) : input(p.name.value, `data-path="parameters.${i}.name.value"`)}</td>
        <td class="py-2 px-2">${input(p.description.value, `data-path="parameters.${i}.description.value"`, { placeholder: '설명' })}</td>
        <td class="py-2 px-2">${p.type.locked
            ? lockedInput(p.type.value)
            : selectInput(p.type.value, ['string', 'integer', 'number', 'boolean'], `data-path="parameters.${i}.type.value"`)}</td>
        <td class="py-2 px-2">${input(p.maxLength.value, `data-path="parameters.${i}.maxLength.value"`, { placeholder: '0', extra: 'text-xs' })}</td>
        <td class="py-2 px-2 text-center">${checkbox(p.required.value, `data-path="parameters.${i}.required.value" data-type="bool"`, { locked: p.required.locked })}</td>
        <td class="py-2 px-2">${input(p.example.value, `data-path="parameters.${i}.example.value"`, { placeholder: '예제값' })}</td>
        <td class="py-2 px-2 text-center">
          ${p.name.locked ? '' : `<button data-del-param="${i}" class="text-red-500 hover:text-red-700">✕</button>`}
        </td>
      </tr>`;
    }).join('');
    return card(
      sectionTitle(isHeader ? '요청 Header' : 'Parameter (Query)', '게이트웨이가 등록한 항목은 잠금됩니다. 추가 항목은 자유롭게 정의하세요.') +
      `<table class="w-full text-sm">
        <thead class="bg-slate-50 text-xs text-slate-500 uppercase">
          <tr>
            <th class="py-2 px-2 text-left w-44">이름</th>
            <th class="py-2 px-2 text-left">설명</th>
            <th class="py-2 px-2 text-left w-28">타입</th>
            <th class="py-2 px-2 text-left w-20">길이</th>
            <th class="py-2 px-2 text-center w-14">필수</th>
            <th class="py-2 px-2 text-left w-40">예제값</th>
            <th class="py-2 px-2 w-10"></th>
          </tr>
        </thead>
        <tbody>${rows || `<tr><td colspan="7" class="py-6 text-center text-sm text-slate-400">등록된 항목이 없습니다</td></tr>`}</tbody>
      </table>
      <div class="mt-3 flex justify-end">
        <button data-add-param="${isHeader ? 'header' : 'query'}" class="px-3 py-1.5 text-sm rounded border border-slate-300 hover:bg-slate-50">+ ${isHeader ? '헤더' : '파라미터'} 추가</button>
      </div>`
    );
  }

  // --- Body 스키마 테이블 ---
  function renderBodyTable() {
    return card(
      sectionTitle('Request Body 스키마', `media type: ${state.data.requestBody.mediaType}. 중첩된 object/array 를 모두 지원합니다.`) +
      `<div class="mb-3 rounded-md border border-amber-200 bg-amber-50 px-3 py-2.5 text-xs text-amber-800 leading-relaxed">
        <div class="font-semibold mb-1">기본 동작과 잠금</div>
        <ul class="list-disc pl-4 space-y-1">
          <li>여기서 설정하는 validation(타입·필수·제약 등)은 <b>실제 게이트웨이(GW) 동작에는 영향을 주지 않습니다.</b> 개발자 포털과 OpenAPI Spec 문서에만 반영되는 참고 정보입니다.</li>
          <li><span class="whitespace-nowrap">🔒 표시된 필드</span>는 <b>GW 레이아웃에 등록된 필드</b>로, 삭제하거나 속성(이름·타입·필수 등)을 변경할 수 없습니다. 추가 필드는 자유롭게 정의·삭제할 수 있습니다.</li>
        </ul>
      </div>` +
      renderSchemaTable(state.data.requestBody.schema, 'requestBody.schema') +
      `<div class="mt-3 flex justify-end">
        <button data-add-row="requestBody.schema" class="px-3 py-1.5 text-sm rounded border border-slate-300 hover:bg-slate-50">+ 최상위 필드 추가</button>
      </div>`
    );
  }

  // --- Response 스키마 테이블 ---
  function renderResponseTable() {
    const codes = Object.keys(state.data.responses);
    const cur = state.responseStatusTab;
    const r = state.data.responses[cur];
    const tabsHtml = codes.map(code => `
      <button data-res-tab="${code}" class="px-3 py-1.5 text-sm rounded-t border-b-2 transition
        ${code === cur ? 'border-brand-600 text-brand-700 font-medium' : 'border-transparent text-slate-600 hover:text-slate-800'}">
        ${escapeHtml(code)}${code !== '200' ? ` <span data-del-status="${code}" class="ml-1 text-red-400 hover:text-red-600" title="상태코드 삭제">✕</span>` : ''}
      </button>
    `).join('');
    return card(
      sectionTitle('Response 스키마', '상태 코드별로 응답 스키마를 정의합니다. 추가한 상태코드는 [요청/응답 샘플]의 응답 예제와 연동됩니다.') +
      `<div class="flex items-center gap-1 border-b border-slate-200 mb-3 flex-wrap">${tabsHtml}
        <button id="btn-add-status" class="ml-2 px-2 py-1 text-sm rounded border border-dashed border-slate-300 text-brand-600 hover:bg-brand-50">+ 상태코드</button>
      </div>` +
      `<div class="mb-3">${fieldRow('설명', input(r.description.value, `data-path="responses.${cur}.description.value"`, { placeholder: '응답 설명' }))}</div>` +
      renderSchemaTable(r.schema, `responses.${cur}.schema`) +
      `<div class="mt-3 flex justify-end">
        <button data-add-row="responses.${cur}.schema" class="px-3 py-1.5 text-sm rounded border border-slate-300 hover:bg-slate-50">+ 필드 추가</button>
      </div>`
    );
  }

  function renderComponentsArea() {
    return card(
      sectionTitle('Components / 재사용 스키마', '여러 오퍼레이션에서 공유할 스키마를 정의합니다.') +
      `<div class="text-sm text-slate-500 text-center py-10 border-2 border-dashed border-slate-200 rounded">
        등록된 컴포넌트가 없습니다. <button class="text-brand-600 hover:underline ml-1" onclick="alert('POC: 컴포넌트 생성 UI 는 추후 확장')">+ 새 스키마 정의</button>
      </div>`
    );
  }

  // 중첩 스키마 테이블 렌더 (재귀)
  function renderSchemaTable(rows, basePath) {
    return `
      <table class="w-full text-sm schema-table">
        <thead class="bg-slate-50 text-xs text-slate-500 uppercase">
          <tr>
            <th class="py-2 px-2 text-left w-16">#</th>
            <th class="py-2 px-2 text-center w-10">잠금</th>
            <th class="py-2 px-2 text-left w-56">필드명</th>
            <th class="py-2 px-2 text-left">설명</th>
            <th class="py-2 px-2 text-left w-28">타입</th>
            <th class="py-2 px-2 text-left w-20">길이</th>
            <th class="py-2 px-2 text-center w-14">필수</th>
            <th class="py-2 px-2 text-left w-40">예제값</th>
            <th class="py-2 px-2 w-10"></th>
          </tr>
        </thead>
        <tbody>
          ${renderSchemaRows(rows, basePath, 0, '')}
        </tbody>
      </table>
    `;
  }
  function renderSchemaRows(rows, basePath, depth, seqPrefix) {
    let html = '';
    rows.forEach((row, idx) => {
      const path = `${basePath}.${idx}`;
      const seq = seqPrefix ? `${seqPrefix}-${idx + 1}` : String(idx + 1);   // 계층 시퀀스 (1, 1-1, 2-1-1 …)
      const isLocked = row.name.locked;
      const isObject = row.type.value === 'object';
      const isArray = row.type.value === 'array';
      const isArrayOfObject = isArray && (row.itemsType && row.itemsType.value) === 'object';
      const expandedKey = `${path}.__expanded`;
      const isExpanded = state[expandedKey] !== false;

      html += `
        <tr class="border-b border-slate-100 ${isLocked ? 'locked-row' : ''}" data-row-path="${path}">
          <td class="py-2 px-2 text-xs text-slate-500 whitespace-nowrap">${seq}</td>
          <td class="py-2 px-2 text-center">${isLocked ? '<span class="text-slate-400 text-xs" title="게이트웨이 동기화">🔒</span>' : ''}</td>
          <td class="py-2 px-2">
            <div class="flex items-center gap-1" style="padding-left:${depth * 18}px;">
              ${isObject || isArrayOfObject
                ? `<button data-toggle-expand="${path}" class="w-4 h-4 text-slate-400 hover:text-slate-700">${isExpanded ? '▾' : '▸'}</button>`
                : `<span class="w-4 inline-block"></span>`}
              <span class="text-xs text-slate-400 mr-1">${isObject ? '{}' : isArray ? '[]' : '·'}</span>
              ${isLocked
                ? `<input type="text" value="${escapeHtml(row.name.value)}" readonly tabindex="-1"
                          class="flex-1 w-full px-2 py-1 text-sm rounded border border-slate-200 bg-slate-100 text-slate-700 cursor-not-allowed" title="API 게이트웨이에서 동기화" />`
                : input(row.name.value, `data-path="${path}.name.value"`, { extra: 'flex-1' })}
            </div>
          </td>
          <td class="py-2 px-2">${input(row.description.value, `data-path="${path}.description.value"`, { placeholder: '설명' })}</td>
          <td class="py-2 px-2">
            ${row.type.locked
              ? `<input type="text" value="${escapeHtml(row.type.value)}" readonly tabindex="-1" class="w-full px-2 py-1 text-sm rounded border border-slate-200 bg-slate-100 text-slate-700 cursor-not-allowed" />`
              : selectInput(row.type.value,
                  ['string', 'integer', 'number', 'boolean', 'array', 'object'],
                  `data-path="${path}.type.value" data-rerender="step3"`)}
          </td>
          <td class="py-2 px-2">
            ${row.type.value === 'string'
              ? input(row.maxLength.value, `data-path="${path}.maxLength.value"`, { placeholder: '0', extra: 'text-xs' })
              : '<span class="text-xs text-slate-300">—</span>'}
          </td>
          <td class="py-2 px-2 text-center">
            ${checkbox(!!row.required.value, `data-path="${path}.required.value" data-type="bool"`, { locked: row.required.locked })}
          </td>
          <td class="py-2 px-2">
            ${isObject ? '<span class="text-xs text-slate-300">— (자식 참조)</span>' :
              isArray
                ? `<div class="flex gap-1 items-center">
                     <span class="text-xs text-slate-400">items:</span>
                     ${selectInput((row.itemsType && row.itemsType.value) || 'string', ['string', 'integer', 'number', 'boolean', 'object'], `data-path="${path}.itemsType.value" data-rerender="step3"`, { extra: 'text-xs' })}
                   </div>`
                : input(row.example.value, `data-path="${path}.example.value"`, { placeholder: '예제값' })}
          </td>
          <td class="py-2 px-2 text-center">
            ${isLocked
              ? ''
              : `<button data-del-row="${path}" class="text-red-500 hover:text-red-700">✕</button>`}
          </td>
        </tr>
      `;

      // children (object + array<object>(GRID) 공통)
      if ((isObject || isArrayOfObject) && isExpanded) {
        if (row.children && row.children.length) {
          html += renderSchemaRows(row.children, `${path}.children`, depth + 1, seq);
        }
        html += `
          <tr class="bg-slate-50/50">
            <td colspan="9" class="py-1.5 px-2" style="padding-left:${(depth + 1) * 18 + 24}px;">
              <button data-add-row="${path}.children" class="text-xs text-brand-600 hover:underline">+ 자식 필드 추가</button>
            </td>
          </tr>
        `;
      }
    });
    return html;
  }

  // ============================================================
  // 9. 인증 / 보안 (기본정보 Step 에 병합된 섹션)
  // ============================================================
  // 테스트베드 프록시 사용 여부(포탈 djb.gateway.use-proxy / token-use-proxy). DJB_CTX 미지정 시 기본 true.
  function useProxyOn()   { return !(window.DJB_CTX && window.DJB_CTX.useProxy === false); }
  function tokenProxyOn() { return !(window.DJB_CTX && window.DJB_CTX.tokenUseProxy === false); }

  // 인증 스킴 → 실제 인증방식 라벨/색상. GW enricher 는 oauth·api_key 를 모두 apiKey-in-header 로 모델링하므로
  // scheme.type("apiKey")만으로는 구분 불가 → authtype(djbAuthType) 및 scheme key(djbOAuth/djbApiKey)로 판별.
  function schemeAuthLabel(s) {
    var at = (djbAuthType || '').trim().toLowerCase();
    if (at === 'oauth' || at === 'oauth2' || at === 'ca' || s.key === 'djbOAuth') {
      return { label: 'OAUTH', cls: 'bg-purple-100 text-purple-700' };
    }
    if (at === 'api_key' || at === 'apikey' || s.key === 'djbApiKey') {
      return { label: 'API KEY', cls: 'bg-emerald-100 text-emerald-700' };
    }
    return { label: (s.type || '').toUpperCase(), cls: 'bg-slate-100 text-slate-700' };
  }

  function renderAuthSection() {
    var schemes = state.data.securitySchemes || [];
    var proxyOn = useProxyOn();
    var notice =
      '<div class="mb-3 text-xs bg-slate-50 border border-slate-200 rounded p-2 leading-relaxed text-slate-600">'
      + '<div>Swagger 테스트 호출: <b>' + (proxyOn ? '포탈 프록시 경유' : '직접 호출') + '</b>'
      + (proxyOn ? ' — <code class="font-mono">/api/call-api</code> 가 인증 토큰·헤더를 대신 주입합니다.'
                 : ' — 브라우저가 대상 주소로 직접 호출(대상 CORS 허용 필요).') + '</div>'
      + '<div>토큰 발급: <b>' + (tokenProxyOn() ? '포탈 프록시 경유' : '직접 호출') + '</b></div>'
      + '<div class="text-slate-400 mt-1">프록시 여부는 포탈 설정 <code class="font-mono">djb.gateway.use-proxy</code> / <code class="font-mono">djb.gateway.token-use-proxy</code> 로 제어됩니다.</div>'
      + '</div>';
    return card(
      sectionTitle('인증 / 보안', '게이트웨이(GW)에 설정된 인증만 표시됩니다. (읽기 전용)') +
      notice +
      (schemes.length
        ? schemes.map((s, i) => renderSecuritySchemeCard(s, i)).join('')
        : '<div class="text-sm text-slate-500 py-2">인증 없음</div>')
    );
  }
  function renderSecuritySchemeCard(s, idx) {
    const auth = schemeAuthLabel(s);

    let body = '';
    if (s.type === 'apiKey') {
      body = `
        <div class="grid grid-cols-2 gap-3 text-sm">
          <div><label class="text-xs text-slate-500 block mb-1">name</label>${lockedInput(s.name ? s.name.value : '')}</div>
          <div><label class="text-xs text-slate-500 block mb-1">in</label>${lockedInput(s.in ? s.in.value : 'header')}</div>
        </div>
      `;
    } else if (s.type === 'http') {
      body = `
        <div class="grid grid-cols-2 gap-3 text-sm">
          <div><label class="text-xs text-slate-500 block mb-1">scheme</label>${lockedInput(s.scheme ? s.scheme.value : '')}</div>
          <div><label class="text-xs text-slate-500 block mb-1">bearerFormat</label>${lockedInput((s.bearerFormat && s.bearerFormat.value) || '')}</div>
        </div>
      `;
    } else if (s.type === 'oauth2') {
      body = `<div class="text-xs text-slate-500">OAuth2</div>`;
    } else if (s.type === 'openIdConnect') {
      body = `<div><label class="text-xs text-slate-500 block mb-1">openIdConnectUrl</label>${lockedInput(s.openIdConnectUrl ? s.openIdConnectUrl.value : '')}</div>`;
    }

    return `
      <div class="border border-slate-200 rounded-lg p-3 mb-3 locked-card">
        <div class="flex items-center gap-2 mb-3">
          <code class="text-sm font-semibold text-slate-800">${escapeHtml(s.key)}</code>
          <span class="px-2 py-0.5 text-[10px] uppercase rounded ${auth.cls}">${escapeHtml(auth.label)}</span>
          <span class="text-xs text-slate-400 ml-1">🔒 GW</span>
        </div>
        ${body}
        ${(s.description && s.description.value) ? `<div class="mt-3 text-xs text-slate-500">${escapeHtml(s.description.value)}</div>` : ''}
      </div>
    `;
  }

  // ============================================================
  // 10. Step 4 — 요청/응답 샘플 (하위 탭: 요청=편집, 응답=읽기전용(응답 정보에서 파생))
  // ============================================================
  // 응답 예제 본문을 응답 정보(스키마·예제값)에서 파생해 동기화 — 응답 샘플은 항상 응답 정보 세팅을 따른다.
  function syncResponseExamplesFromSchema() {
    var d = state.data;
    Object.keys(d.responses || {}).forEach(function (code) {
      var set = d.examples.response[code] || (d.examples.response[code] = { body: '', headers: [] });
      set.body = JSON.stringify(_sampleObj((d.responses[code] || {}).schema || []), null, 2);
    });
  }

  function renderStep5(root) {
    const tab = state.sampleTab || 'request';
    const tabs = [{ key: 'request', label: '요청' }, { key: 'response', label: '응답' }];
    const taCls = 'w-full px-3 py-2 text-xs font-mono rounded border border-slate-300 focus:outline-none focus:ring-2 focus:ring-brand-500';
    let body;
    if (tab === 'request') {
      const _mt = (state.data.requestBody && state.data.requestBody.mediaType) || 'application/json';
      const req = (state.data.examples.request[_mt] || { default: '' }).default || '';
      body = card(
        sectionTitle('요청 본문 예제 (application/json)') +
        `<div class="mb-2 flex justify-end"><button id="btn-regen-req-ex" class="px-2 py-1 text-xs rounded border border-slate-300 hover:bg-slate-50">스키마로 재생성</button></div>` +
        `<textarea data-example-target="request" rows="10" class="${taCls}">${escapeHtml(req)}</textarea>` +
        `<div class="mt-2 text-xs text-slate-400">스키마와 자동 매핑되어 Swagger UI 의 예제 영역에 노출됩니다.</div>`
      );
    } else {
      syncResponseExamplesFromSchema();   // 응답 정보 세팅 → 예제 파생(읽기 전용)
      const codes = Object.keys(state.data.examples.response);
      const cur = state.responseStatusTab;
      const curSet = state.data.examples.response[cur] || { body: '', headers: [] };
      body = card(
        sectionTitle('응답 예제 (읽기 전용)', '응답 정보(스키마·예제값)에서 자동 생성됩니다. 수정은 [응답 정보] 탭에서 하세요.') +
        `<div class="flex items-center gap-1 border-b border-slate-200 mb-3 flex-wrap">
          ${codes.map(code => `
            <button data-ex-res-tab="${code}" class="px-3 py-1.5 text-sm rounded-t border-b-2
              ${cur === code ? 'border-brand-600 text-brand-700 font-medium' : 'border-transparent text-slate-600'}">${escapeHtml(code)}</button>
          `).join('')}
        </div>` +
        `<label class="text-xs text-slate-500 block mb-1">응답 본문 (${escapeHtml(cur)})</label>` +
        `<textarea data-example-target="response" rows="12" readonly class="${taCls} bg-slate-50">${escapeHtml(curSet.body || '')}</textarea>` +
        `<div class="mt-2 text-xs text-slate-400">응답 헤더는 [응답 정보 > Header] 에서 관리합니다.</div>`
      );
    }
    root.innerHTML = subTabBar(tabs, tab, 'data-sample-tab') + body;
    initExampleEditors(tab);   // 요청=편집 CodeMirror, 응답=읽기전용 CodeMirror
  }

  // (구 요청/응답(웹페이지) 스텝은 요청/응답 정보의 'API 설명(HTML)' 서브 탭으로 흡수됨)

  // 스키마 → 설명 자료 HTML 표(필드명/타입/필수/길이/설명). 현재 편집 중 스키마(추가 필드 포함) 기준.
  function schemaToSpecHtml(rows) {
    var out = '<table style="border-collapse:collapse;width:100%" border="1"><thead><tr>'
      + '<th style="padding:4px 8px;background:#f1f5f9;text-align:left">필드명</th>'
      + '<th style="padding:4px 8px;background:#f1f5f9;text-align:center">타입</th>'
      + '<th style="padding:4px 8px;background:#f1f5f9;text-align:center">필수</th>'
      + '<th style="padding:4px 8px;background:#f1f5f9;text-align:center">길이</th>'
      + '<th style="padding:4px 8px;background:#f1f5f9;text-align:left">설명</th></tr></thead><tbody>';
    function walk(list, depth) {
      (list || []).forEach(function (r) {
        if (!r || !r.name) return;
        var pad = new Array(depth + 1).join('&nbsp;&nbsp;&nbsp;&nbsp;');
        var req = 'Y';   // 설명 자료 HTML: 필수 여부 기본값 = 필수(Y)
        var len = (r.maxLength && r.maxLength.value) || '';
        // 필드명·설명 = 좌측 정렬, 나머지(타입·필수·길이) = 가운데 정렬
        out += '<tr>'
          + '<td style="padding:4px 8px">' + pad + escapeHtml(r.name.value || '') + '</td>'
          + '<td style="padding:4px 8px;text-align:center">' + escapeHtml(r.type.value || '') + '</td>'
          + '<td style="padding:4px 8px;text-align:center">' + req + '</td>'
          + '<td style="padding:4px 8px;text-align:center">' + escapeHtml(String(len)) + '</td>'
          + '<td style="padding:4px 8px">' + escapeHtml((r.description && r.description.value) || '') + '</td></tr>';
        if (r.children && r.children.length) walk(r.children, depth + 1);
      });
    }
    walk(rows, 0);
    out += '</tbody></table>';
    return out;
  }
  function regenReqSpec() {
    var d = state.data;
    d.msgSpec.request = schemaToSpecHtml(d.requestBody ? d.requestBody.schema : []);
    renderForm();
    toast('요청 설명 자료를 스키마 기준으로 재생성했습니다');
  }
  function regenResSpec() {
    var d = state.data;
    var r = (d.responses && (d.responses['200'] || d.responses[Object.keys(d.responses)[0]])) || {};
    d.msgSpec.response = schemaToSpecHtml(r.schema || []);
    renderForm();
    toast('응답 설명 자료를 스키마 기준으로 재생성했습니다');
  }

  // 개발자포탈과 동일한 Summernote 툴바 + 편집영역(.editor-content) 스타일
  var DJB_SN_TOOLBAR = [
    ['style', ['style']],
    ['font', ['bold', 'underline', 'clear']],
    ['color', ['color']],
    ['para', ['ul', 'ol', 'paragraph']],
    ['table', ['table']],
    ['insert', ['link', 'picture']],
    ['view', ['fullscreen', 'codeview', 'help']]
  ];
  function addEditorContentClass() { if (window.jQuery) window.jQuery('.note-editable').addClass('editor-content'); }

  // 설명 자료 = Summernote 리치 에디터. state.data.msgSpec[key] 에 HTML 저장.
  function initSpecEditor(sel, key, height) {
    if (!(window.jQuery && window.jQuery.fn && window.jQuery.fn.summernote)) return;
    var $e = window.jQuery(sel);
    if (!$e.length) return;
    try { $e.summernote('destroy'); } catch (e) {}
    var koOk = window.jQuery.summernote && window.jQuery.summernote.lang && window.jQuery.summernote.lang['ko-KR'];
    $e.summernote({
      height: height || 300,
      lang: koOk ? 'ko-KR' : 'en-US',
      placeholder: '여기에 내용을 입력하세요.',
      toolbar: DJB_SN_TOOLBAR,
      callbacks: {
        onInit: addEditorContentClass,
        onChange: function (contents) { state.data.msgSpec[key] = contents; }
      }
    });
    $e.summernote('code', state.data.msgSpec[key] || '');
    addEditorContentClass();
  }

  // 요청/응답 본문 예제 textarea 를 CodeMirror(JSON) 에디터로 승격. renderStep5 마다 재생성(폼 재렌더 시 wipe→재초기화).
  // 요청 = 편집 가능, 응답 = 읽기 전용(응답 정보에서 파생).
  function initExampleEditors() {
    if (!window.CodeMirror) return;
    var cmOpt = { mode: { name: 'javascript', json: true }, lineNumbers: true, lineWrapping: true, viewportMargin: Infinity, tabSize: 2 };
    var reqTa = $('[data-example-target="request"]');
    if (reqTa) {
      var cmReq = CodeMirror.fromTextArea(reqTa, cmOpt);
      cmReq.setSize('100%', 300);
      cmReq.on('change', function (c) {
        var mt = (state.data.requestBody && state.data.requestBody.mediaType) || 'application/json';
        state.data.examples.request[mt] = state.data.examples.request[mt] || { default: '' };
        state.data.examples.request[mt].default = c.getValue();
        schedulePreviewSync();
      });
    }
    var resTa = $('[data-example-target="response"]');
    if (resTa) {
      var cmRes = CodeMirror.fromTextArea(resTa, Object.assign({}, cmOpt, { readOnly: true }));
      cmRes.setSize('100%', 300);
    }
  }

  // 전체 URL → { origin, path }. Mock Server URL 을 서버+경로로 분해(경로를 별도로 덧붙이지 않기 위함).
  function splitFullUrl(u) {
    u = (u || '').trim();
    if (!u) return { origin: '', path: '/' };
    try {
      var parsed = new URL(u);
      var path = (parsed.pathname || '/') + (parsed.search || '');
      if (!path || path.charAt(0) !== '/') path = '/' + path;
      return { origin: parsed.origin, path: path };
    } catch (e) {
      // 스킴 누락/파싱 실패 → 통째 origin 취급, path 는 '/'
      return { origin: u.replace(/\/+$/, ''), path: '/' };
    }
  }

  // 응답유형별 실제 호출 주소. gw 는 [호스트 + 경로], mock 은 입력한 전체 URL 그대로(경로 미부착).
  function resolvedCallUrl() {
    var d = state.data;
    var rt = (d.portal && d.portal.responseType) || 'sample';
    if (rt === 'mock') return (((d.portal && d.portal.mockUrl) || '').trim()) || '(주소 없음)';
    var host = (rt === 'gw') ? ((window.DJB_CTX && window.DJB_CTX.gwAddress) || 'http://127.0.0.1:39310') : '';
    var path = (d.operation && d.operation.path && d.operation.path.value) || '';
    if (!host) return path || '(주소 없음)';
    return host.replace(/\/+$/, '') + (path.charAt(0) === '/' ? path : '/' + path);
  }

  // ============================================================
  // 11. Step 6 — 문서 옵션 + 풀 미리보기
  // ============================================================
  function renderStep6(root) {
    const p = state.data.portal || {};
    const roles = p.roles || {};
    const rt = p.responseType || 'sample';
    const showOrg = !!(roles.ROLE_CORP_USER || roles.ROLE_CORP_MANAGER);   // 법인사용자/관리자 선택 시에만 공개법인 노출
    const orgChips = (p.orgs || []).map((o, i) =>
      `<span class="tag-chip inline-flex items-center gap-1 px-2 py-0.5 text-xs rounded-full bg-brand-50 border border-brand-200 text-brand-700 mr-1">${escapeHtml(o.orgName || o.id)}<b data-del-org="${i}" class="cursor-pointer text-brand-400 hover:text-brand-700">✕</b></span>`
    ).join('');
    const groupChips = (p.apiGroups || []).map((g, i) =>
      `<span class="tag-chip inline-flex items-center gap-1 px-2 py-0.5 text-xs rounded-full bg-brand-50 border border-brand-200 text-brand-700 mr-1">${escapeHtml(g.groupName || g.id)}<b data-del-group="${i}" class="cursor-pointer text-brand-400 hover:text-brand-700">✕</b></span>`
    ).join('');
    root.innerHTML =
      card(
        sectionTitle('포탈 게시 정보', '개발자포탈 공개 설정. 기존 API 스펙 화면의 값을 불러와 적용합니다.') +
        fieldRow('API 그룹',
          `<div class="flex flex-col gap-1">
            <div class="flex items-center gap-2 flex-wrap">
              <span id="group-chips">${groupChips || '<span class="text-xs text-slate-400">선택된 그룹 없음</span>'}</span>
              <button type="button" id="btn-select-group" class="px-2 py-1 text-sm rounded border border-slate-300 hover:bg-slate-50">+ 선택</button>
            </div>
            <div class="text-xs text-slate-400">API 그룹은 apiGroupMan(API 그룹 관리)에서도 관리됩니다.</div>
          </div>`) +
        fieldRow('공개 여부', `<label class="inline-flex items-center gap-2 text-sm">${checkbox(p.displayYn === 'Y', 'data-portal-yn')} 공개</label>`) +
        fieldRow('공개 권한',
          `<div class="flex flex-col gap-1">
            <div class="flex gap-4">
              <label class="inline-flex items-center gap-1 text-sm">${checkbox(!!roles.ROLE_USER, 'data-role="ROLE_USER"')} 개인사용자</label>
              <label class="inline-flex items-center gap-1 text-sm">${checkbox(!!roles.ROLE_CORP_USER, 'data-role="ROLE_CORP_USER"')} 법인사용자</label>
              <label class="inline-flex items-center gap-1 text-sm">${checkbox(!!roles.ROLE_CORP_MANAGER, 'data-role="ROLE_CORP_MANAGER"')} 법인관리자</label>
            </div>
            <div class="text-xs text-slate-400">전부 선택 시 로그인 사용자 전체에게 공개됩니다.</div>
          </div>`) +
        (showOrg ? fieldRow('공개 법인',
          `<div class="flex flex-col gap-1">
            <div class="flex items-center gap-2 flex-wrap">
              <span id="org-chips">${orgChips || '<span class="text-xs text-slate-400">선택된 법인 없음</span>'}</span>
              <button type="button" id="btn-select-org" class="px-2 py-1 text-sm rounded border border-slate-300 hover:bg-slate-50">+ 선택</button>
            </div>
            <div class="text-xs text-slate-400">특정 법인에게만 공개하고 싶을 때 공개 법인을 선택하시면 됩니다.</div>
          </div>`) : '') +
        fieldRow('응답 유형',
          '<div>' +
          ['sample', 'mock', 'gw'].map(v =>
            `<label class="inline-flex items-center gap-1 text-sm mr-3"><input type="radio" name="portal-rt" data-portal-rt="${v}" ${rt === v ? 'checked' : ''}> ${v === 'gw' ? 'GW' : v}</label>`
          ).join('') +
          (rt === 'sample'
            ? `<div class="mt-2 text-xs text-slate-500">응답 샘플을 바로 리턴합니다.</div>`
            : `<div class="mt-2 text-xs text-slate-500">실제 호출 주소: <code id="resolved-call-url" class="font-mono text-slate-700 break-all">${escapeHtml(resolvedCallUrl())}</code></div>`
               + `<div class="mt-1 text-xs text-slate-500">호출 방식: <b>${useProxyOn() ? '포탈 프록시 경유' : '직접 호출'}</b>`
               + (useProxyOn() ? ' (<code class="font-mono">/api/call-api</code> 경유, 인증 헤더 자동 주입)' : ' (브라우저 → 대상 직접 호출, 대상 CORS 허용 필요)') + `</div>`) +
          (rt === 'gw' ? `<div class="mt-1 text-xs text-slate-400">GW 주소는 포탈 설정(PTL_PROPERTY, 그룹 Portal)의 <code class="font-mono">djb.gateway.base-url</code> 값을 사용합니다.</div>` : '') +
          '</div>') +
        (rt === 'mock' ? fieldRow('Mock Server URL', input(p.mockUrl || '', 'data-portal="mockUrl"', { placeholder: 'https://mock.example.com/api/v1/foo' }), { hint: 'Mock 응답을 제공할 전체 호출 URL(경로 포함). 응답 유형이 Mock 일 때 이 주소로 그대로 호출하며, 어댑터/오퍼레이션 경로를 뒤에 덧붙이지 않습니다.' }) : '')
      ) +
      card(
        sectionTitle('설명 (API 소개 페이지)', 'API 소개 페이지에만 표시됩니다. OpenAPI 스펙 파일에는 포함되지 않습니다.') +
        '<div id="info-desc-editor">' + (state.data.info.description.value || '') + '</div>'
      ) +
      card(
        sectionTitle('내보내기', 'OpenAPI 스펙을 파일로 저장합니다.') +
        `<div class="flex gap-2">
          <button data-export="yaml" class="px-4 py-2 text-sm rounded bg-brand-600 text-white hover:bg-brand-700">YAML 다운로드</button>
          <button data-export="json" class="px-4 py-2 text-sm rounded border border-brand-300 text-brand-700 hover:bg-brand-50">JSON 다운로드</button>
        </div>`
      );
    initInfoDescEditor();   // 설명 리치 에디터 (API 소개 페이지 전용)
  }

  // ============================================================
  // 12. 미리보기 (Swagger / Monaco)
  // ============================================================
  let monacoEditor = null;
  let monacoReady = false;
  let swaggerInstance = null;

  // admin 오프라인 환경: Monaco(AMD/CDN) 대신 CodeMirror(YAML, script/JSP 에서 로드) 사용.
  function initMonaco() {
    return new Promise(resolve => {
      monacoEditor = CodeMirror($('#preview-monaco'), {
        value: '', mode: 'yaml', readOnly: true, lineNumbers: true, lineWrapping: false
      });
      monacoEditor.setSize('100%', '100%');
      monacoReady = true;
      resolve();
    });
  }

  function mountSwagger() {
    const spec = buildOpenApiSpec();
    swaggerInstance = SwaggerUIBundle({
      spec: spec,
      domNode: $('#preview-swagger'),
      deepLinking: false,
      presets: [SwaggerUIBundle.presets.apis, SwaggerUIStandalonePreset],
      layout: state.data.docOptions.layout,
      defaultModelsExpandDepth: state.data.docOptions.defaultExpandDepth,
      defaultModelExpandDepth: state.data.docOptions.defaultExpandDepth,
      docExpansion: state.data.docOptions.defaultExpandDepth === -1 ? 'full' : (state.data.docOptions.defaultExpandDepth === 0 ? 'none' : 'list'),
      tryItOutEnabled: !!state.data.docOptions.tryItOut,
      filter: !!state.data.docOptions.tagFilter.length
    });
  }
  function reMountSwagger() { mountSwagger(); }

  function updateMonacoFromState() {
    if (!monacoReady) return;
    const spec = buildOpenApiSpec();
    const yaml = jsyaml.dump(spec, { noRefs: true, lineWidth: 120 });
    if (monacoEditor.getValue() !== yaml) monacoEditor.setValue(yaml);
  }

  function setPreviewTab(tab) {
    state.previewTab = tab;
    $$('.preview-tab').forEach(b => {
      const active = b.dataset.ptab === tab;
      b.classList.toggle('border-brand-600', active);
      b.classList.toggle('text-brand-700', active);
      b.classList.toggle('font-medium', active);
      b.classList.toggle('border-transparent', !active);
      b.classList.toggle('text-slate-600', !active);
    });
    $('#preview-swagger').classList.toggle('hidden', tab !== 'swagger');
    $('#preview-monaco').classList.toggle('hidden', tab === 'swagger');
    $('#yaml-edit-bar').classList.toggle('hidden', tab !== 'yaml-edit');

    if (monacoReady && tab !== 'swagger') {
      monacoEditor.setOption('readOnly', tab !== 'yaml-edit');
      updateMonacoFromState();
      setTimeout(() => monacoEditor.refresh(), 50);
    }
    if (tab === 'swagger') reMountSwagger();
    applyEditLock(tab === 'yaml-edit');
  }

  // 스펙 편집 모드: 폼 입력 30% 알파 레이어 차단 + 탭/단계 전환 잠금
  function applyEditLock(on) {
    // 입력 UI(form-content) + 상단 탭(stepper) 를 30% 알파로 낮추고 클릭 차단. (오버레이/position 변경 없이 → 레이아웃 안전)
    var fc = $('#form-content');
    var stp = $('#stepper');
    var bp = $('#btn-prev'), bn = $('#btn-next'), bh = $('#btn-toggle-preview');
    if (on) {
      if (fc) { fc.style.opacity = '0.3'; fc.style.pointerEvents = 'none'; }
      if (stp) { stp.style.opacity = '0.3'; stp.style.pointerEvents = 'none'; }
      if (bp) bp.disabled = true;
      if (bn) bn.disabled = true;
      if (bh) { bh.disabled = true; bh.style.opacity = '0.5'; bh.style.pointerEvents = 'none'; }
      $$('.preview-tab').forEach(function (b) { if (b.dataset.ptab !== 'yaml-edit') { b.style.pointerEvents = 'none'; b.style.opacity = '0.5'; } });
    } else {
      if (fc) { fc.style.opacity = ''; fc.style.pointerEvents = ''; }
      if (stp) { stp.style.opacity = ''; stp.style.pointerEvents = ''; }
      if (bp) bp.disabled = (state.step === 1);
      if (bn) bn.disabled = false;
      if (bh) { bh.disabled = false; bh.style.opacity = ''; bh.style.pointerEvents = ''; }
      $$('.preview-tab').forEach(function (b) { b.style.pointerEvents = ''; b.style.opacity = ''; });
    }
  }

  const schedulePreviewSync = debounce(() => {
    if (!state.showPreview) return;
    if (state.previewTab === 'swagger') reMountSwagger();
    else if (state.previewTab !== 'yaml-edit') updateMonacoFromState();
  }, 300);

  // ============================================================
  // 13. Form 데이터 변경 이벤트 (delegated)
  // ============================================================
  function setIn(obj, path, value) {
    const parts = path.split('.');
    let cur = obj;
    for (let i = 0; i < parts.length - 1; i++) {
      const k = isNaN(parts[i]) ? parts[i] : Number(parts[i]);
      if (cur[k] == null) cur[k] = isNaN(parts[i + 1]) ? {} : [];
      cur = cur[k];
    }
    cur[isNaN(parts[parts.length - 1]) ? parts[parts.length - 1] : Number(parts[parts.length - 1])] = value;
  }
  function getIn(obj, path) {
    return path.split('.').reduce((c, k) => (c == null ? c : c[isNaN(k) ? k : Number(k)]), obj);
  }

  function bindFormEvents() {
    const form = $('#form-content');

    form.addEventListener('input', e => {
      const t = e.target;
      const p = t.dataset.path;
      if (!p) return;
      let v = t.type === 'checkbox' ? t.checked : t.value;
      if (t.dataset.type === 'bool') v = !!v;
      if (t.dataset.type === 'int')  v = parseInt(v, 10);
      // 'data.xxx' prefix 는 state 직접경로, 아니면 state.data 하위
      if (p.startsWith('data.')) setIn(state, p, v);
      else setIn(state.data, p, v);
      schedulePreviewSync();
    });

    form.addEventListener('change', e => {
      const t = e.target;
      if (t.dataset.rerender === 'step3') { renderStep3Section(); return; }
      if (t.dataset.rerender === 'preview') { reMountSwagger(); return; }
    });

    form.addEventListener('click', e => {
      const target = e.target;

      // Step 3: 섹션 사이드 네비
      const secBtn = target.closest('[data-sec]');
      if (secBtn) {
        state.step3Section = secBtn.dataset.sec;
        renderStep3();
        return;
      }

      // 상태코드 삭제 (탭 안의 ✕) — 탭 전환(data-res-tab / data-ex-res-tab)보다 먼저 처리
      const delStatus = target.closest('[data-del-status]');
      if (delStatus) {
        const dcode = delStatus.dataset.delStatus;
        if (dcode !== '200') {
          delete state.data.responses[dcode];
          delete state.data.examples.response[dcode];
          state.responseStatusTab = state.data.examples.response['200'] ? '200' : (Object.keys(state.data.examples.response)[0] || '200');
          renderForm();
          schedulePreviewSync();
        }
        return;
      }

      // Response status tab
      const resTab = target.closest('[data-res-tab]');
      if (resTab) {
        state.responseStatusTab = resTab.dataset.resTab;
        renderStep3Section();
        return;
      }
      // 상태코드 추가
      if (target.id === 'btn-add-status') {
        const code = (window.prompt('추가할 HTTP 상태코드 (100~599)', '400') || '').trim();
        if (!code) return;
        if (!/^[1-5][0-9]{2}$/.test(code)) { toast('유효한 상태코드가 아닙니다', 'warn'); return; }
        if (state.data.examples.response[code]) { state.responseStatusTab = code; renderForm(); return; }
        state.data.responses[code] = { description: { value: '', locked: false }, schema: [] };
        state.data.examples.response[code] = { body: '{}', headers: [] };
        state.responseStatusTab = code;
        renderForm();
        schedulePreviewSync();
        return;
      }

      // 예제 응답 탭
      const exResTab = target.closest('[data-ex-res-tab]');
      if (exResTab) {
        state.responseStatusTab = exResTab.dataset.exResTab;
        renderForm();
        return;
      }

      // 요청/응답 샘플 서브 탭 전환
      const sampleTabBtn = target.closest('[data-sample-tab]');
      if (sampleTabBtn) { state.sampleTab = sampleTabBtn.dataset.sampleTab; renderForm(); return; }

      // 요청/응답 정보 서브 탭 전환
      const reqInfoTabBtn = target.closest('[data-reqinfo-tab]');
      if (reqInfoTabBtn) { state.reqInfoTab = reqInfoTabBtn.dataset.reqinfoTab; renderForm(); return; }
      const resInfoTabBtn = target.closest('[data-resinfo-tab]');
      if (resInfoTabBtn) { state.resInfoTab = resInfoTabBtn.dataset.resinfoTab; renderForm(); return; }

      // 행 추가
      const addRow = target.closest('[data-add-row]');
      if (addRow) {
        const targetPath = addRow.dataset.addRow;
        const arr = getIn(state.data, targetPath) || [];
        const parent = getIn(state.data, targetPath.replace(/\.children$/, ''));
        if (targetPath.endsWith('.children') && parent && !parent.children) parent.children = [];
        const target2 = targetPath.endsWith('.children') ? parent.children : arr;
        target2.push(newSchemaRow());
        if (targetPath.endsWith('.children')) state[`${targetPath.replace(/\.children$/, '')}.__expanded`] = true;
        renderStep3Section();
        schedulePreviewSync();
        return;
      }

      // 행 삭제
      const delRow = target.closest('[data-del-row]');
      if (delRow) {
        const p = delRow.dataset.delRow;
        const parts = p.split('.');
        const idx = Number(parts.pop());
        const parent = getIn(state.data, parts.join('.'));
        parent.splice(idx, 1);
        renderStep3Section();
        schedulePreviewSync();
        return;
      }

      // 펼침 토글
      const expBtn = target.closest('[data-toggle-expand]');
      if (expBtn) {
        const key = `${expBtn.dataset.toggleExpand}.__expanded`;
        state[key] = state[key] === false ? true : false;
        renderStep3Section();
        return;
      }

      // 제약 더보기 토글
      const conBtn = target.closest('[data-toggle-constraints]');
      if (conBtn) {
        const key = `${conBtn.dataset.toggleConstraints}.__constraints`;
        state[key] = !state[key];
        renderStep3Section();
        return;
      }

      // 파라미터 삭제
      const delParam = target.closest('[data-del-param]');
      if (delParam) {
        state.data.parameters.splice(Number(delParam.dataset.delParam), 1);
        renderStep3Section();
        schedulePreviewSync();
        return;
      }
      const addParam = target.closest('[data-add-param]');
      if (addParam) {
        var np = newParamRow();
        np.in = addParam.dataset.addParam || 'query';
        state.data.parameters.push(np);
        renderStep3Section();
        schedulePreviewSync();
        return;
      }

      // 보안 스킴 삭제 / 추가
      const delSec = target.closest('[data-del-sec]');
      if (delSec) {
        state.data.securitySchemes.splice(Number(delSec.dataset.delSec), 1);
        renderStep1($('#form-content'));
        schedulePreviewSync();
        return;
      }
      if (target.id === 'btn-add-sec') { openSecModal(); return; }

      // 응답 헤더 추가 / 삭제 (현재 상태코드 세트)
      if (target.id === 'btn-add-header') {
        const curH = state.data.examples.response[state.responseStatusTab];
        if (curH) { curH.headers = curH.headers || []; curH.headers.push({ name: '', type: 'string', description: '', example: '' }); }
        renderForm();
        schedulePreviewSync();
        return;
      }
      const delHeader = target.closest('[data-del-header]');
      if (delHeader) {
        const curD = state.data.examples.response[state.responseStatusTab];
        if (curD && curD.headers) curD.headers.splice(Number(delHeader.dataset.delHeader), 1);
        renderForm();
        schedulePreviewSync();
        return;
      }

      // 예제 스키마 기준 재생성
      if (target.id === 'btn-regen-req-ex') { regenReqExample(); return; }
      if (target.id === 'btn-regen-res-ex') { regenResExample(); return; }

      // 설명 자료 파라미터/스키마 기준 재생성
      if (target.id === 'btn-regen-req-spec') { regenReqSpec(); return; }
      if (target.id === 'btn-regen-res-spec') { regenResSpec(); return; }

      // 공개 법인 선택/삭제
      if (target.id === 'btn-select-org') { openOrgPopup(); return; }
      const delOrg = target.closest('[data-del-org]');
      if (delOrg) {
        state.data.portal.orgs.splice(Number(delOrg.dataset.delOrg), 1);
        state.data.portal.displayOrg = state.data.portal.orgs.map(function (o) { return o.id; }).join(',');
        renderForm();
        return;
      }

      // API 그룹 선택/삭제
      if (target.id === 'btn-select-group') { openGroupPopup(); return; }
      const delGroup = target.closest('[data-del-group]');
      if (delGroup) {
        state.data.portal.apiGroups.splice(Number(delGroup.dataset.delGroup), 1);
        renderForm();
        return;
      }

      // Export
      const exportBtn = target.closest('[data-export]');
      if (exportBtn) {
        downloadSpec(exportBtn.dataset.export);
        return;
      }
    });

    // 오퍼레이션 태그 토글
    form.addEventListener('change', e => {
      const ot = e.target.closest('[data-op-tag]');
      if (ot) {
        const name = ot.dataset.opTag;
        const arr = state.data.operation.tags.value;
        if (ot.checked && !arr.includes(name)) arr.push(name);
        if (!ot.checked) arr.splice(arr.indexOf(name), 1);
        schedulePreviewSync();
      }
      // 전역 보안
      const gs = e.target.closest('[data-global-sec]');
      if (gs) {
        const k = gs.dataset.globalSec;
        const arr = state.data.globalSecurity;
        if (gs.checked && !arr.includes(k)) arr.push(k);
        if (!gs.checked) arr.splice(arr.indexOf(k), 1);
        schedulePreviewSync();
      }
      // 태그 필터
      const tf = e.target.closest('[data-tag-filter]');
      if (tf) {
        const k = tf.dataset.tagFilter;
        const arr = state.data.docOptions.tagFilter;
        if (tf.checked && !arr.includes(k)) arr.push(k);
        if (!tf.checked) arr.splice(arr.indexOf(k), 1);
        reMountSwagger();
      }
      // 포탈 게시 정보 (공개여부/권한/응답유형)
      if (e.target.closest('[data-portal-yn]')) { state.data.portal.displayYn = e.target.checked ? 'Y' : 'N'; }
      const rl = e.target.closest('[data-role]');
      if (rl) { state.data.portal.roles[rl.dataset.role] = e.target.checked; renderForm(); }  // 공개법인 노출 토글
      const prt = e.target.closest('[data-portal-rt]');
      if (prt && e.target.checked) { state.data.portal.responseType = prt.dataset.portalRt; renderForm(); reMountSwagger(); }  // Mock URL 노출 토글 + 서버 갱신
    });

    // 예제 textarea
    form.addEventListener('input', e => {
      const t = e.target;
      if (t.dataset.exampleTarget === 'request') {
        var _mt = (state.data.requestBody && state.data.requestBody.mediaType) || 'application/json';
        state.data.examples.request[_mt] = state.data.examples.request[_mt] || { default: '' };
        state.data.examples.request[_mt].default = t.value;
        schedulePreviewSync();
      }
      if (t.dataset.exampleTarget === 'response') {
        const curB = state.data.examples.response[state.responseStatusTab];
        if (curB) { curB.body = t.value; schedulePreviewSync(); }
      }
      // 응답 헤더 인라인 편집 (현재 상태코드 세트)
      if (t.dataset.respHeader) {
        const [i, field] = t.dataset.respHeader.split('.');
        const curSet = state.data.examples.response[state.responseStatusTab];
        if (curSet && curSet.headers && curSet.headers[Number(i)]) { curSet.headers[Number(i)][field] = t.value; schedulePreviewSync(); }
      }
      // 포탈 게시 정보 (공개법인 / Mock URL)
      if (t.dataset.portal === 'displayOrg') { state.data.portal.displayOrg = t.value; }
      if (t.dataset.portal === 'mockUrl') { state.data.portal.mockUrl = t.value; var _u = $('#resolved-call-url'); if (_u) _u.textContent = resolvedCallUrl(); }
    });
  }
  function newSchemaRow() {
    return {
      name: { value: '', locked: false },
      type: { value: 'string', locked: false },
      required: { value: false, locked: false },
      format: { value: '', locked: false },
      maxLength: { value: '', locked: false },
      pattern: { value: '', locked: false },
      description: { value: '', locked: false },
      example: { value: '', locked: false },
      children: null
    };
  }
  function newParamRow() {
    return {
      in: 'query',
      name: { value: '', locked: false },
      type: { value: 'string', locked: false },
      required: { value: false, locked: false },
      format: { value: '', locked: false },
      maxLength: { value: '', locked: false },
      pattern: { value: '', locked: false },
      description: { value: '', locked: false },
      example: { value: '', locked: false }
    };
  }

  // ============================================================
  // 14. 보안 스킴 추가 모달
  // ============================================================
  function openSecModal() {
    const modal = $('#sec-modal');
    let chosenType = 'apiKey';
    const renderBody = () => {
      $('#sec-modal-body').innerHTML = `
        <div class="mb-4">
          <label class="block text-xs text-slate-500 mb-2">키 이름 (components 에 등록될 식별자)</label>
          <input id="sec-key" placeholder="MyAuth" class="w-full px-3 py-1.5 text-sm border border-slate-300 rounded" />
        </div>
        <div class="mb-4">
          <label class="block text-xs text-slate-500 mb-2">타입</label>
          <div class="grid grid-cols-4 gap-2">
            ${['apiKey', 'http', 'oauth2', 'openIdConnect'].map(t => `
              <button data-sec-type="${t}" class="sec-type-btn px-3 py-2 text-xs rounded border ${chosenType === t ? 'bg-brand-50 border-brand-400 text-brand-700' : 'bg-white border-slate-300 text-slate-600'}">${escapeHtml(t)}</button>
            `).join('')}
          </div>
        </div>
        <div id="sec-detail"></div>
      `;
      const detail = $('#sec-detail');
      if (chosenType === 'apiKey') {
        detail.innerHTML = `
          <div class="grid grid-cols-2 gap-3">
            <div><label class="text-xs text-slate-500 block mb-1">name</label><input id="sec-name" placeholder="X-API-Key" class="w-full px-3 py-1.5 text-sm border border-slate-300 rounded" /></div>
            <div><label class="text-xs text-slate-500 block mb-1">in</label>
              <select id="sec-in" class="w-full px-3 py-1.5 text-sm border border-slate-300 rounded">
                <option value="header">header</option><option value="query">query</option><option value="cookie">cookie</option>
              </select>
            </div>
          </div>`;
      } else if (chosenType === 'http') {
        detail.innerHTML = `
          <div class="grid grid-cols-2 gap-3">
            <div><label class="text-xs text-slate-500 block mb-1">scheme</label>
              <select id="sec-scheme" class="w-full px-3 py-1.5 text-sm border border-slate-300 rounded">
                <option value="bearer">bearer</option><option value="basic">basic</option><option value="digest">digest</option>
              </select>
            </div>
            <div><label class="text-xs text-slate-500 block mb-1">bearerFormat</label>
              <input id="sec-bearer" placeholder="JWT" class="w-full px-3 py-1.5 text-sm border border-slate-300 rounded" />
            </div>
          </div>`;
      } else if (chosenType === 'oauth2') {
        detail.innerHTML = `
          <div class="text-xs text-slate-500 mb-2">Flow 타입을 선택하세요 (POC 에서는 최소 메타만 저장)</div>
          <select id="sec-flow" class="w-full px-3 py-1.5 text-sm border border-slate-300 rounded mb-3">
            <option value="implicit">implicit</option>
            <option value="password">password</option>
            <option value="clientCredentials">clientCredentials</option>
            <option value="authorizationCode">authorizationCode</option>
          </select>
          <label class="text-xs text-slate-500 block mb-1">authorizationUrl</label>
          <input id="sec-authUrl" placeholder="https://auth.example.com/oauth/authorize" class="w-full px-3 py-1.5 text-sm border border-slate-300 rounded mb-2" />
          <label class="text-xs text-slate-500 block mb-1">tokenUrl</label>
          <input id="sec-tokenUrl" placeholder="https://auth.example.com/oauth/token" class="w-full px-3 py-1.5 text-sm border border-slate-300 rounded" />
        `;
      } else {
        detail.innerHTML = `
          <label class="text-xs text-slate-500 block mb-1">openIdConnectUrl</label>
          <input id="sec-oidc" placeholder="https://example.com/.well-known/openid-configuration" class="w-full px-3 py-1.5 text-sm border border-slate-300 rounded" />
        `;
      }
    };
    renderBody();
    modal.classList.remove('hidden');

    modal.onclick = (e) => {
      if (e.target.dataset.secClose !== undefined || e.target.closest('[data-sec-close]')) {
        modal.classList.add('hidden');
        return;
      }
      const tb = e.target.closest('[data-sec-type]');
      if (tb) { chosenType = tb.dataset.secType; renderBody(); }
    };
    $('#btn-sec-save').onclick = () => {
      const key = $('#sec-key').value.trim();
      if (!key) return toast('키 이름을 입력하세요', 'warn');
      if (state.data.securitySchemes.some(s => s.key === key)) return toast('이미 존재하는 키입니다', 'error');
      const newS = { key, type: chosenType, locked: false, description: { value: '', locked: false } };
      if (chosenType === 'apiKey') { newS.name = { value: $('#sec-name').value, locked: false }; newS.in = { value: $('#sec-in').value, locked: false }; }
      if (chosenType === 'http') { newS.scheme = { value: $('#sec-scheme').value, locked: false }; newS.bearerFormat = { value: $('#sec-bearer').value, locked: false }; }
      if (chosenType === 'oauth2') {
        const flow = $('#sec-flow').value;
        newS.flows = { [flow]: { authorizationUrl: $('#sec-authUrl') ? $('#sec-authUrl').value : '', tokenUrl: $('#sec-tokenUrl') ? $('#sec-tokenUrl').value : '', scopes: {} } };
      }
      if (chosenType === 'openIdConnect') newS.openIdConnectUrl = { value: $('#sec-oidc').value, locked: false };
      state.data.securitySchemes.push(newS);
      modal.classList.add('hidden');
      renderStep1($('#form-content'));
      schedulePreviewSync();
      toast(`보안 스킴 "${key}" 추가 완료`);
    };
  }

  // ============================================================
  // 15. 미리보기 패널 토글 / 레이아웃
  // ============================================================
  function setPreviewVisibility(show) {
    state.showPreview = show;
    $('#preview-panel').style.display = show ? '' : 'none';
    $('#form-panel').style.width = show ? '960px' : '1600px';
    $('#btn-toggle-preview-from-form').classList.toggle('hidden', show);
    if (show && monacoReady) setTimeout(() => monacoEditor.refresh(), 100);
    if (show) schedulePreviewSync();
  }
  function ensureFullPreviewLayout() {
    if (!state.showPreview) return;
    $('#form-panel').style.width = '480px';
    $('#preview-panel').style.width = '1120px';
    if (monacoReady) setTimeout(() => monacoEditor.refresh(), 100);
    reMountSwagger();
  }
  function restoreNormalPreviewLayout() {
    if (!state.showPreview) return;
    $('#form-panel').style.width = '960px';
    $('#preview-panel').style.width = '640px';
    if (monacoReady) setTimeout(() => monacoEditor.refresh(), 100);
  }

  // ============================================================
  // 16. Export
  // ============================================================
  function downloadSpec(format) {
    const spec = buildOpenApiSpec();
    if (format === 'yaml') { doDownload(jsyaml.dump(spec, { noRefs: true }), 'text/yaml', 'yaml'); return; }
    if (format === 'json') { doDownload(JSON.stringify(spec, null, 2), 'application/json', 'json'); return; }
    // html — 폐쇄망 자립: 벤더 swagger css/js 를 fetch 해 인라인
    var base = (window.DJB_CTX && window.DJB_CTX.swaggerBase) || '';
    Promise.all([
      fetch(base + 'swagger-ui.css').then(r => r.text()),
      fetch(base + 'swagger-ui-bundle.js').then(r => r.text())
    ]).then(function (a) {
      doDownload(buildStandaloneHtml(spec, a[0], a[1]), 'text/html', 'html');
    }).catch(function (e) { toast('HTML 번들 생성 실패: ' + e.message, 'error'); });
  }
  function doDownload(content, mime, ext) {
    const blob = new Blob([content], { type: mime });
    const a = document.createElement('a');
    a.href = URL.createObjectURL(blob);
    a.download = `openapi-${state.data.info.title.value || 'api'}.${ext}`;
    a.click();
    setTimeout(() => URL.revokeObjectURL(a.href), 1000);
    toast(`${ext.toUpperCase()} 다운로드를 시작했습니다`);
  }
  // 벤더 swagger 를 인라인한 완전 자립(offline) HTML 번들
  function buildStandaloneHtml(spec, css, js) {
    return `<!doctype html><html lang="${state.data.docOptions.lang}"><head><meta charset="utf-8" />
<title>${escapeHtml(spec.info.title)} API 문서</title>
<style>${css}</style>
</head><body><div id="ui"></div>
<script>${js}<\/script>
<script>SwaggerUIBundle({spec:${JSON.stringify(spec)},dom_id:'#ui'});<\/script>
</body></html>`;
  }

  // ============================================================
  // 17. 전역 인터랙션 바인딩
  // ============================================================
  function bindGlobalEvents() {
    $('#btn-prev').addEventListener('click', () => goStep(state.step - 1));
    $('#btn-next').addEventListener('click', () => {
      if (state.step < STEPS.length) goStep(state.step + 1);
      else toast('모든 단계를 완료했습니다. 우측 미리보기 또는 내보내기를 사용하세요.');
    });

    $('#btn-toggle-preview').addEventListener('click', () => setPreviewVisibility(false));
    $('#btn-toggle-preview-from-form').addEventListener('click', () => setPreviewVisibility(true));

    $$('.preview-tab').forEach(b => b.addEventListener('click', () => setPreviewTab(b.dataset.ptab)));

    $('#btn-yaml-apply').addEventListener('click', () => {
      if (!monacoReady) return;
      try {
        const parsed = jsyaml.load(monacoEditor.getValue());
        if (!parsed || !parsed.info) throw new Error('info 필드가 없습니다');
        // 전체 reverse-sync: 편집한 스펙 → state.data 전면 재구성 (specToData 역변환).
        // docOptions 는 OpenAPI 에 없는 렌더 옵션이라 사용자 값 보존.
        const keepDoc = state.data.docOptions;
        state.data = specToData(parsed);
        state.data.docOptions = keepDoc;
        state.responseStatusTab = state.data.responses && state.data.responses['200']
          ? '200' : (state.data.responses ? (Object.keys(state.data.responses)[0] || '200') : '200');
        toast('편집한 스펙을 Form 전체에 적용했습니다.');
        renderForm();
        setPreviewTab('swagger');
      } catch (err) {
        toast('YAML/스펙 파싱 오류: ' + err.message, 'error');
      }
    });
    $('#btn-yaml-revert').addEventListener('click', () => {
      updateMonacoFromState();
      setPreviewTab('yaml-ro');   // 변경 취소 → YAML 보기로 전환(편집 잠금 해제)
      toast('변경을 취소했습니다');
    });

    $('#btn-export').addEventListener('click', () => $('#export-menu').classList.toggle('hidden'));
    document.addEventListener('click', e => {
      if (!e.target.closest('#btn-export') && !e.target.closest('#export-menu')) $('#export-menu').classList.add('hidden');
    });
    $('#export-menu').addEventListener('click', e => {
      const b = e.target.closest('[data-export]');
      if (b) { downloadSpec(b.dataset.export); $('#export-menu').classList.add('hidden'); }
    });

    var bSave = $('#btn-save'); if (bSave) bSave.addEventListener('click', () => saveSpec(false));
    var bRegen = $('#btn-regen'); if (bRegen) bRegen.addEventListener('click', regenerate);
    var bClose = $('#btn-close'); if (bClose) bClose.addEventListener('click', () => window.close());

    // Cmd/Ctrl + S → 저장 (브라우저 기본 저장 대화상자 차단)
    document.addEventListener('keydown', function (e) {
      if ((e.metaKey || e.ctrlKey) && (e.key === 's' || e.key === 'S')) {
        e.preventDefault();
        saveSpec(false);
      }
    });

    // iframe(모달)로 열렸을 때만 "새창으로 띄우기" 노출 — 현재 URL 을 새 창으로 열고 모달 닫기
    if (window.self !== window.top) {
      var bNew = $('#btn-newwin');
      if (bNew) {
        bNew.classList.remove('hidden');
        bNew.addEventListener('click', function () { window.open(window.location.href, '_blank'); try { window.close(); } catch (e) {} });
      }
    }
  }

  // ============================================================
  // 17b. 서버 연동 (DETAIL_SPEC 로드 / SAVE) + OpenAPI → state.data 변환
  // ============================================================
  function wrap(v, locked) { return { value: v == null ? '' : v, locked: !!locked }; }
  function exToStr(v) { return v == null ? '' : (typeof v === 'string' ? v : JSON.stringify(v, null, 2)); }
  function toForm(o) { return Object.keys(o).map(k => encodeURIComponent(k) + '=' + encodeURIComponent(o[k] == null ? '' : o[k])).join('&'); }

  function propToRow(name, p, required) {
    p = p || {};
    var t = p.type || 'string';
    var gw = !!p['x-djb-gw'];   // GW 레이아웃 유래 필드 → 항목명/타입만 잠금. 필수는 잠금이어도 편집 가능.
    var row = {
      name: wrap(name, gw), type: wrap(t, gw), required: wrap(!!required, false),
      format: wrap(p.format || ''), maxLength: wrap(p.maxLength == null ? '' : p.maxLength),
      pattern: wrap(p.pattern || ''), description: wrap(p.description || ''),
      example: wrap(p.example == null ? '' : p.example), children: null
    };
    if (t === 'object') row.children = schemaToRows(p);
    else if (t === 'array') {
      var itemType = (p.items && p.items.type) || 'string';
      row.itemsType = wrap(itemType);
      // array<object>(GW GRID): items.properties 를 자식 행으로 복원. (누락 시 자식 필드 유실)
      if (itemType === 'object') row.children = schemaToRows(p.items);
    }
    return row;
  }
  function schemaToRows(schema) {
    if (!schema || !schema.properties) return [];
    var req = schema.required || [];
    return Object.keys(schema.properties).map(k => propToRow(k, schema.properties[k], req.indexOf(k) >= 0));
  }

  // OpenAPI(3.0) → POC state.data 형태로 변환. 구조 기본값은 SAMPLE_DATA 에서 가져와 유지.
  function specToData(spec) {
    var d = deepClone(window.SAMPLE_DATA);
    if (!spec || typeof spec !== 'object') return d;
    d.__openapi = spec.openapi || '3.1.0';   // openapi 버전 보존(3.0/3.1 모두), 기본 3.1.0
    d.portal = { displayYn: 'Y', roles: { ROLE_USER: false, ROLE_CORP_USER: false, ROLE_CORP_MANAGER: false }, displayOrg: '', orgs: [], apiGroups: [], responseType: 'sample', mockUrl: '' };
    d.msgSpec = { request: '', response: '' };   // 요청/응답 메시지 스펙(HTML, API 소개 페이지용) = apiRequestSpec/apiResponseSpec
    var info = spec.info || {};
    d.info.title = wrap(info.title || '', false);
    d.info.version = wrap(info.version || '1.0.0', false);
    d.info.description = wrap(info.description || '');
    d.info.summary = wrap((spec.tags && spec.tags[0] && spec.tags[0].description) || info.title || '');
    d.info.termsOfService = wrap(info.termsOfService || '');
    d.info.contact = { name: wrap(info.contact && info.contact.name || ''), email: wrap(info.contact && info.contact.email || ''), url: wrap(info.contact && info.contact.url || '') };
    d.info.license = { name: wrap(info.license && info.license.name || ''), url: wrap(info.license && info.license.url || '') };
    d.tags = (spec.tags || []).map(t => ({ name: t.name, description: t.description || '' }));
    d.externalDocs = { description: wrap(spec.externalDocs && spec.externalDocs.description || ''), url: wrap(spec.externalDocs && spec.externalDocs.url || '') };
    d.servers = (spec.servers || []).map(s => ({ url: wrap(s.url, true), env: '', description: wrap(s.description || '') }));
    d.serverVariables = [];

    var paths = spec.paths || {}; var pk = Object.keys(paths)[0] || '';
    var pi = paths[pk] || {}; var methods = ['get', 'post', 'put', 'delete', 'patch', 'options', 'head'];
    var mk = methods.filter(m => pi[m])[0] || 'get'; var op = pi[mk] || {};
    d.operation = {
      method: wrap((mk || 'get').toUpperCase(), true), path: wrap(pk, true),
      operationId: wrap(op.operationId || ''), summary: wrap(op.summary || ''),
      description: wrap(op.description || ''), tags: { value: op.tags || [], locked: false },
      deprecated: { value: !!op.deprecated, locked: false }
    };
    d.parameters = (op.parameters || []).map(function (p) {
      var s = p.schema || {};
      return { in: p.in || 'query', name: wrap(p.name), type: wrap(s.type || 'string'), required: wrap(!!p.required), format: wrap(s.format || ''), maxLength: wrap(s.maxLength == null ? '' : s.maxLength), pattern: wrap(s.pattern || ''), description: wrap(p.description || ''), example: wrap(p.example == null ? '' : p.example) };
    });
    var rb = op.requestBody || {}; var rbc = rb.content || {}; var mt = Object.keys(rbc)[0] || 'application/json';
    d.requestBody = { mediaType: mt, required: wrap(!!rb.required), schema: schemaToRows((rbc[mt] || {}).schema) };

    d.responses = {};
    var resp = op.responses || {};
    var codes = Object.keys(resp);
    if (!codes.length) { d.responses = { '200': { description: wrap('OK'), schema: [] } }; }
    else codes.forEach(function (code) {
      var r = resp[code] || {}; var c = r.content || {}; var m2 = Object.keys(c)[0];
      d.responses[code] = { description: wrap(r.description || ''), schema: schemaToRows(m2 ? c[m2].schema : null) };
    });
    d.responseStatusTab = d.responses['200'] ? '200' : Object.keys(d.responses)[0];

    var comps = spec.components || {}; var ss = comps.securitySchemes || {};
    d.securitySchemes = Object.keys(ss).map(function (key) {
      var s = ss[key] || {}; var o = { key: key, type: s.type || 'apiKey', locked: false, description: wrap(s.description || '') };
      if (s.type === 'apiKey') { o.name = wrap(s.name || ''); o.in = wrap(s.in || 'header'); }
      else if (s.type === 'http') { o.scheme = wrap(s.scheme || 'bearer'); o.bearerFormat = wrap(s.bearerFormat || ''); }
      else if (s.type === 'oauth2') { o.flows = s.flows || {}; }
      else if (s.type === 'openIdConnect') { o.openIdConnectUrl = wrap(s.openIdConnectUrl || ''); }
      return o;
    });
    d.globalSecurity = (spec.security || []).map(r => Object.keys(r)[0]).filter(Boolean);

    // examples — 상태코드별 { body, headers[] } 세트
    d.examples = { request: {}, response: {} };
    d.examples.request[mt] = { default: exToStr((rbc[mt] || {}).example) };
    Object.keys(d.responses).forEach(function (code) {
      var r = resp[code] || {}; var c = r.content || {}; var m2 = Object.keys(c)[0];
      var headers = [];
      if (r.headers) {
        Object.keys(r.headers).forEach(function (hn) {
          var h = r.headers[hn] || {};
          headers.push({ name: hn, type: (h.schema && h.schema.type) || 'string', description: h.description || '', example: h.example == null ? '' : String(h.example) });
        });
      }
      d.examples.response[code] = { body: exToStr(m2 ? c[m2].example : ''), headers: headers };
    });
    return d;
  }

  // 스키마 기반 샘플 JSON 생성 (자동생성 예제)
  function _coerce(t, v) { if (t === 'integer') return parseInt(v, 10) || 0; if (t === 'number') return parseFloat(v) || 0; if (t === 'boolean') return v === true || v === 'true'; return v; }
  // 기본 예제값: string→sample_명(길이 지정 시 맞춰 자름), integer→123456, number→123456.123, boolean→false
  //  - 숫자는 길이 지정 시 정수부를 그 길이에 맞춰 자름(실수는 소수 .123 유지)
  function _defScalar(t, name, maxLen) {
    var ml = parseInt(maxLen, 10);
    if (t === 'integer') {
      var iv = '123456';
      if (ml && ml > 0 && iv.length > ml) iv = iv.substring(0, ml);
      return parseInt(iv, 10);
    }
    if (t === 'number') {
      var ip = '123456';
      if (ml && ml > 0 && ip.length > ml) ip = ip.substring(0, ml);
      return parseFloat(ip + '.123');
    }
    if (t === 'boolean') return false;
    var v = 'sample_' + (name || '');
    if (ml && ml > 0 && v.length > ml) v = v.substring(0, ml);
    return v;
  }
  function _sampleVal(row) {
    var t = row.type.value, ex = row.example && row.example.value;
    if (t === 'object') return _sampleObj(row.children || []);
    if (t === 'array') { var it = (row.itemsType && row.itemsType.value) || 'string'; return [it === 'object' ? _sampleObj(row.children || []) : _defScalar(it, (row.name && row.name.value) || 'item', '')]; }
    if (ex !== undefined && ex !== '') return _coerce(t, ex);
    return _defScalar(t, (row.name && row.name.value) || '', row.maxLength && row.maxLength.value);
  }
  function _sampleObj(rows) { var o = {}; (rows || []).forEach(function (r) { if (r && r.name && r.name.value) o[r.name.value] = _sampleVal(r); }); return o; }
  function ensureExamplesFromSchema(force) {
    var d = state.data;
    d.examples = d.examples || { request: {}, response: {}, responseHeaders: [] };
    var mt = (d.requestBody && d.requestBody.mediaType) || 'application/json';
    d.examples.request = d.examples.request || {};
    var curReq = d.examples.request[mt] && d.examples.request[mt].default;
    if (force || !curReq) {
      d.examples.request[mt] = { default: JSON.stringify(_sampleObj(d.requestBody ? d.requestBody.schema : []), null, 2) };
    }
    d.examples.response = d.examples.response || {};
    if (d.responses && d.responses['200'] && (force || !d.examples.response['200'])) {
      d.examples.response['200'] = JSON.stringify(_sampleObj(d.responses['200'].schema || []), null, 2);
    }
  }

  // 예제 재생성 — 현재 편집 중(메모리) 스키마 기준 + 자동생성과 동일 값 규칙(D10). 추가/미저장 필드 반영.
  function regenReqExample() {
    var d = state.data;
    var mt = (d.requestBody && d.requestBody.mediaType) || 'application/json';
    d.examples.request = d.examples.request || {};
    d.examples.request[mt] = { default: JSON.stringify(_sampleObj(d.requestBody ? d.requestBody.schema : []), null, 2) };
    renderForm(); schedulePreviewSync();
    toast('요청 예제 재생성(현재 스키마 기준)');
  }
  function regenResExample() {
    var d = state.data;
    var code = state.responseStatusTab;
    var r = d.responses[code] || {};
    var set = d.examples.response[code] || (d.examples.response[code] = { body: '', headers: [] });
    set.body = JSON.stringify(_sampleObj(r.schema || []), null, 2);
    renderForm(); schedulePreviewSync();
    toast(code + ' 응답 예제 재생성(현재 스키마 기준)');
  }

  // 스키마 각 스칼라 필드의 '예제값'(row.example.value) 을 기본 규칙(_defScalar)으로 채움(재귀).
  function fillSchemaExamples(rows) {
    (rows || []).forEach(function (r) {
      if (!r || !r.name) return;
      var t = r.type.value;
      if (t === 'object' || t === 'array') {
        if (r.children && r.children.length) fillSchemaExamples(r.children);
      } else {
        r.example = r.example || { value: '', locked: false };
        r.example.value = String(_defScalar(t, r.name.value || '', r.maxLength && r.maxLength.value));
      }
    });
  }

  // 자동생성(초기화) 시 백엔드가 준 기초 스펙 위에, 추가자료(예제·설명자료)를 프론트에서 스키마 기준 생성.
  // 재생성 버튼(_sampleObj / schemaToSpecHtml)과 동일 로직 → 자동생성 결과 == 재생성 결과.
  function frontAutoGenerate() {
    var d = state.data;
    var mt = (d.requestBody && d.requestBody.mediaType) || 'application/json';
    // 1) 스키마 필드별 예제값 채움 (요청 + 모든 응답)
    fillSchemaExamples(d.requestBody ? d.requestBody.schema : []);
    Object.keys(d.responses || {}).forEach(function (code) { fillSchemaExamples((d.responses[code] || {}).schema || []); });
    // 2) 요청/응답 본문 예제 JSON (채워진 예제값 사용)
    d.examples = d.examples || { request: {}, response: {} };
    d.examples.request = d.examples.request || {};
    d.examples.request[mt] = { default: JSON.stringify(_sampleObj(d.requestBody ? d.requestBody.schema : []), null, 2) };
    d.examples.response = d.examples.response || {};
    Object.keys(d.responses || {}).forEach(function (code) {
      var set = d.examples.response[code] || (d.examples.response[code] = { body: '', headers: [] });
      set.body = JSON.stringify(_sampleObj((d.responses[code] || {}).schema || []), null, 2);
    });
    d.msgSpec = d.msgSpec || { request: '', response: '' };
    d.msgSpec.request = schemaToSpecHtml(d.requestBody ? d.requestBody.schema : []);
    var r200 = (d.responses && (d.responses['200'] || d.responses[Object.keys(d.responses)[0]])) || {};
    d.msgSpec.response = schemaToSpecHtml(r200.schema || []);
  }

  // 공개 법인 선택 팝업(기존 apiSpecManDisplayOrgPopup 재사용) — 자체 iframe 모달. 팝업이 window.parent.selectOrg 호출.
  // 공통 iframe 피커 모달(법인/그룹). 팝업이 same-origin 이라 window.parent.<callback> 호출 + close shim 으로 닫기.
  function openPickerModal(url, title) {
    closePickerModal();
    var ov = document.createElement('div');
    ov.id = 'picker-modal';
    ov.style.cssText = 'position:fixed;inset:0;background:rgba(15,23,42,0.45);z-index:1000;display:flex;align-items:center;justify-content:center;';
    ov.innerHTML = '<div style="background:#fff;width:480px;height:600px;border-radius:8px;overflow:hidden;display:flex;flex-direction:column;box-shadow:0 10px 40px rgba(0,0,0,.3);">'
      + '<div style="display:flex;justify-content:space-between;align-items:center;padding:8px 12px;border-bottom:1px solid #e2e8f0;font-weight:600;">' + escapeHtml(title) + ' <button type="button" id="picker-modal-x" style="border:0;background:none;font-size:16px;cursor:pointer;">✕</button></div>'
      + '<iframe src="' + url + '" style="flex:1;border:0;width:100%;"></iframe></div>';
    document.body.appendChild(ov);
    ov.addEventListener('click', function (e) { if (e.target === ov) closePickerModal(); });
    var x = ov.querySelector('#picker-modal-x'); if (x) x.addEventListener('click', closePickerModal);
    var ifr = ov.querySelector('iframe');
    if (ifr) ifr.addEventListener('load', function () { try { ifr.contentWindow.close = function () { closePickerModal(); }; } catch (e) {} });
  }
  function closePickerModal() { var ov = document.getElementById('picker-modal'); if (ov && ov.parentNode) ov.parentNode.removeChild(ov); }

  function openOrgPopup() {
    var ctx = window.DJB_CTX || {};
    if (!ctx.orgPopupUrl) { toast('법인 팝업을 열 수 없습니다', 'error'); return; }
    var menuId = new URLSearchParams(location.search).get('menuId') || '';
    var ids = (state.data.portal.orgs || []).map(function (o) { return o.id; }).join(',');
    openPickerModal(ctx.orgPopupUrl + '?cmd=POPUP_LIST&serviceType=APIGW&menuId=' + encodeURIComponent(menuId) + '&selectedOrgIds=' + encodeURIComponent(ids), '공개 법인 선택');
  }
  window.selectOrg = function (orgs) {
    var p = state.data.portal;
    p.orgs = orgs || [];
    p.displayOrg = p.orgs.map(function (o) { return o.id; }).join(',');
    closePickerModal();
    renderForm();
  };

  function openGroupPopup() {
    var ctx = window.DJB_CTX || {};
    if (!ctx.groupPopupUrl) { toast('그룹 팝업을 열 수 없습니다', 'error'); return; }
    var menuId = new URLSearchParams(location.search).get('menuId') || '';
    var ids = (state.data.portal.apiGroups || []).map(function (g) { return g.id; }).join(',');
    openPickerModal(ctx.groupPopupUrl + '&serviceType=APIGW&menuId=' + encodeURIComponent(menuId) + '&selectedGroupIds=' + encodeURIComponent(ids), 'API 그룹 선택');
  }
  window.selectApiGroup = function (groups) {
    state.data.portal.apiGroups = groups || [];
    closePickerModal();
    renderForm();
  };

  function loadInitialData() {
    var ctx = window.DJB_CTX || {};
    if (!ctx.eaiSvcName || !ctx.detailUrl) return Promise.resolve();
    return fetch(ctx.detailUrl, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8' }, body: 'eaiSvcName=' + encodeURIComponent(ctx.eaiSvcName) })
      .then(r => r.json())
      .then(function (res) {
        if (res && res.testbedSpec) {
          try { state.data = specToData(JSON.parse(res.testbedSpec)); } catch (e) { console.error('specToData', e); }
        }
        djbAuthType = (res && res.authType) || '';   // 인증 badge 라벨 판별용(oauth/api_key)
        // 자동생성 규칙(title/tag/summary=API명, 예제, 서버prepend, opId)은 백엔드에서 적용됨 → 그대로 로드
        state.step = 1; state.responseStatusTab = state.data.responses && state.data.responses['200'] ? '200' : (state.data.responses ? Object.keys(state.data.responses)[0] : '200');
        var _nm = decodeEntities(ctx.apiName || '') || state.data.info.title.value || '';
        var hp = $('#hdr-project-name'); if (hp) hp.textContent = ctx.eaiSvcName + (_nm ? ' - ' + _nm : '');   // 인터페이스ID - 이름
        var hv = $('#hdr-version'); if (hv) hv.textContent = 'v' + (state.data.info.version.value || '1.0.0');
        var badge = $('#hdr-source'); if (badge && res.source) { badge.textContent = res.source === 'saved' ? '저장본' : '자동생성'; badge.classList.remove('hidden'); }
        // 포탈 게시 정보 — 기존 API 스펙 컬럼값 적용
        var pp = state.data.portal;
        if (res.displayYn) pp.displayYn = res.displayYn;
        if (res.displayRoleCode) res.displayRoleCode.split(',').forEach(function (rc) { rc = rc.trim(); if (pp.roles.hasOwnProperty(rc)) pp.roles[rc] = true; });
        if (res.displayOrg) {
          pp.displayOrg = res.displayOrg;
          pp.orgs = res.displayOrg.split(',').map(function (id) { id = id.trim(); return { id: id, orgName: id }; }).filter(function (o) { return o.id; });
        }
        if (res.responseType) pp.responseType = res.responseType;
        if (res.mockUrl) pp.mockUrl = decodeEntities(res.mockUrl);
        if (res.apiGroups) { try { pp.apiGroups = JSON.parse(res.apiGroups) || []; } catch (e) { pp.apiGroups = []; } }
        // 요청/응답 메시지 스펙(HTML) — 저장본은 저장값, 자동생성분은 프론트에서 생성
        state.data.msgSpec = state.data.msgSpec || { request: '', response: '' };
        if (res.apiRequestSpec) state.data.msgSpec.request = decodeEntities(res.apiRequestSpec);
        if (res.apiResponseSpec) state.data.msgSpec.response = decodeEntities(res.apiResponseSpec);
        // 저장본이 아니면(자동생성/최초 생성) 추가자료(예제·설명자료)를 프론트에서 스키마 기준 생성
        if ((res.source || '') !== 'saved') frontAutoGenerate();
      })
      .catch(e => console.error('loadInitialData', e));
  }

  function saveSpec(temp) {
    var ctx = window.DJB_CTX || {};
    if (!ctx.saveUrl || !ctx.eaiSvcName) { toast('저장 대상이 없습니다', 'error'); return; }
    var spec = buildOpenApiSpec();
    var d = state.data;
    var reqEx = (d.examples && d.examples.request && d.examples.request[d.requestBody && d.requestBody.mediaType || 'application/json'] || {}).default || '';
    var resEx = (d.examples && d.examples.response && d.examples.response['200'] && d.examples.response['200'].body) || '';
    var params = {
      apiId: ctx.eaiSvcName,
      apiName: d.info.title.value || ctx.eaiSvcName,
      apiSimpleDescription: (d.info.summary && d.info.summary.value) || '',
      apiUrl: d.operation.path.value || '',
      apiMethod: d.operation.method.value || 'GET',
      apiContentType: (d.requestBody && d.requestBody.mediaType) || 'application/json',
      description: d.info.description.value || '',
      testbedSpec: JSON.stringify(spec),
      sampleRequest: reqEx,
      sampleResponse: resEx,
      // 요청/응답 메시지 스펙(HTML, API 소개 페이지용)
      apiRequestSpec: (d.msgSpec && d.msgSpec.request) || '',
      apiResponseSpec: (d.msgSpec && d.msgSpec.response) || '',
      // 포탈 게시 정보
      displayYn: (d.portal && d.portal.displayYn) || 'N',
      displayRoleCode: d.portal ? Object.keys(d.portal.roles).filter(function (k) { return d.portal.roles[k]; }).join(',') : '',
      displayOrg: (d.portal && d.portal.displayOrg) || '',
      responseType: (d.portal && d.portal.responseType) || 'sample',
      mockUrl: (d.portal && d.portal.mockUrl) || '',
      apiGroupIds: (d.portal && d.portal.apiGroups) ? d.portal.apiGroups.map(function (g) { return g.id; }).join(',') : ''
    };
    fetch(ctx.saveUrl, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8' }, body: toForm(params) })
      .then(r => r.json())
      .then(function (res) {
        if (res && res.status === 'success') {
          toast(temp ? '임시저장되었습니다' : '저장되었습니다');
          var badge = $('#hdr-source'); if (badge) { badge.textContent = '저장본'; badge.className = 'ml-1 px-2 py-0.5 text-[11px] rounded-full bg-emerald-50 text-emerald-700 border border-emerald-200'; badge.classList.remove('hidden'); }
        } else toast('저장 실패: ' + (res && res.errorMsg || ''), 'error');
      })
      .catch(e => toast('저장 실패: ' + e.message, 'error'));
  }

  // 자동 생성(초기화): 현재 입력을 무시하고 게이트웨이 레이아웃 기반으로 강제 재생성(regen=true)
  function regenerate() {
    var ctx = window.DJB_CTX || {};
    if (!ctx.eaiSvcName || !ctx.detailUrl) { toast('대상이 없습니다', 'error'); return; }
    if (!window.confirm('현재 입력값을 모두 무시하고 게이트웨이 레이아웃 기반으로 다시 자동 생성합니다. 계속할까요?')) return;
    fetch(ctx.detailUrl + '&regen=true', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8' }, body: 'eaiSvcName=' + encodeURIComponent(ctx.eaiSvcName) })
      .then(r => r.json())
      .then(function (res) {
        if (res && res.testbedSpec) {
          try { state.data = specToData(JSON.parse(res.testbedSpec)); }
          catch (e) { toast('자동 생성 실패: 스펙 파싱 오류', 'error'); return; }
        }
        djbAuthType = (res && res.authType) || '';   // 인증 badge 라벨 판별용(oauth/api_key)
        // 구조(title/tag/summary/opId/스키마/보안/헤더)는 백엔드(generateSpec)가 적용한 기초 스펙 그대로 로드.
        // 추가자료(예제·설명자료)는 프론트에서 스키마 기준 생성 → 재생성 버튼과 동일 결과.
        frontAutoGenerate();
        state.step = 1;
        state.responseStatusTab = state.data.responses && state.data.responses['200'] ? '200' : (state.data.responses ? (Object.keys(state.data.responses)[0] || '200') : '200');
        var badge = $('#hdr-source'); if (badge) { badge.textContent = '자동생성'; badge.classList.remove('hidden'); }
        renderStepper(); renderForm(); setPreviewTab('swagger'); reMountSwagger();
        toast('게이트웨이 레이아웃 기반으로 다시 생성했습니다.');
      })
      .catch(e => toast('자동 생성 실패: ' + e.message, 'error'));
  }

  // ============================================================
  // 18. Init
  // ============================================================
  function init() {
    loadInitialData().then(function () {
      renderStepper();
      renderForm();
      bindGlobalEvents();
      bindFormEvents();
      mountSwagger();
      initMonaco().then(updateMonacoFromState);
    });
  }

  document.addEventListener('DOMContentLoaded', init);
})();
