/* ── Tab navigation ────────────────────────────────────────── */
document.querySelectorAll('.nav-item').forEach(item => {
  item.addEventListener('click', () => {
    const tab = item.dataset.tab;
    document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
    document.querySelectorAll('.pane').forEach(p => p.classList.remove('active'));
    item.classList.add('active');
    document.getElementById(`pane-${tab}`).classList.add('active');
    // nm-dash: 탭 진입 시 API 호출 없음 — 조회 버튼으로만 동작
    // pb: 탭 진입 시 API 호출 없음 — ▼ 옵션 불러오기 버튼으로만 동작
    // hist: 탭 진입 시 API 호출 없음
    if (tab === 'opcsvr') srvLoad();
  });
});

/* ── Helpers ────────────────────────────────────────────────── */
function esc(s) {
  return String(s ?? '')
    .replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}

function setGlobal(state, text) {
  document.getElementById('g-dot').className = `dot ${state}`;
  document.getElementById('g-txt').textContent = text.toUpperCase();
}

function log(id, lines) {
  const el = document.getElementById(id);
  el.classList.remove('hidden');
  el.innerHTML = lines.map(l => `<div class="ll ${l.c}">${esc(l.t)}</div>`).join('');
  el.scrollTop = el.scrollHeight;
}

async function api(method, path, body) {
  const opt = { method, headers: { 'Content-Type': 'application/json' } };
  if (body) opt.body = JSON.stringify(body);
  const res = await fetch(path, opt);
  if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
  return res.json();
}

/* ─────────────────────────────────────────────────────────────
   01  인증서 관리
───────────────────────────────────────────────────────────── */
async function certCreate() {
  const clientHostName  = document.getElementById('c-host').value.trim();
  const subjectAltNames = document.getElementById('c-san').value
    .split(',').map(s => s.trim()).filter(Boolean);
  const pfxPassword = document.getElementById('c-pw').value;

  setGlobal('busy', '인증서 생성 중');
  try {
    const d = await api('POST', '/api/certificate/create', {
      clientHostName, subjectAltNames, pfxPassword
    });
    log('cert-log', [
      { c: d.success ? 'ok' : 'err', t: (d.success ? '✅ ' : '❌ ') + d.message },
      ...(d.thumbPrint ? [{ c: 'inf', t: '   Thumbprint : ' + d.thumbPrint }] : [])
    ]);
    setGlobal(d.success ? 'ok' : 'err', d.success ? '인증서 완료' : '오류');
    if (d.success) {
      document.getElementById('cert-dot').classList.add('on');
      await certStatus();
    }
  } catch (e) {
    log('cert-log', [{ c: 'err', t: '❌ ' + e.message }]);
    setGlobal('err', '오류');
  }
}

async function certStatus() {
  const clientHostName = document.getElementById('c-host').value.trim() || 'dbsvr';
  try {
    const d = await api('GET', `/api/certificate/status?clientHostName=${encodeURIComponent(clientHostName)}`);
    const box = document.getElementById('cert-disp');
    if (d.exists) {
      box.innerHTML = `
        <div class="kv"><span class="kk">상태</span><span class="kv2 ok">✅ 인증서 있음</span></div>
        <div class="kv"><span class="kk">Subject</span><span class="kv2">${esc(d.subjectName)}</span></div>
        <div class="kv"><span class="kk">만료일</span><span class="kv2">${d.notAfter ? new Date(d.notAfter).toLocaleDateString('ko-KR') : '-'}</span></div>
        <div class="kv"><span class="kk">Thumbprint</span><span class="kv2">${d.thumbPrint ? d.thumbPrint.slice(0,20)+'…' : '-'}</span></div>
        <div class="kv"><span class="kk">파일 경로</span><span class="kv2">${esc(d.filePath)}</span></div>
      `;
      document.getElementById('cert-dot').classList.add('on');
      setGlobal('ok', '인증서 확인됨');
    } else {
      box.innerHTML = `
        <div class="kv"><span class="kk">상태</span><span class="kv2 err">❌ 인증서 없음</span></div>
        <div class="kv"><span class="kk">경로</span><span class="kv2">${esc(d.filePath)}</span></div>
      `;
      setGlobal('', 'READY');
    }
  } catch {}
}

/* ─────────────────────────────────────────────────────────────
   02  서버 접속 테스트
───────────────────────────────────────────────────────────── */
function getServerCfg(p) {
  return {
    serverHostName: document.getElementById(`${p}-server`).value.trim(),
    port:           parseInt(document.getElementById(`${p}-port`).value) || 4840,
    clientHostName: document.getElementById(`${p}-client`).value.trim(),
    userName:       document.getElementById(`${p}-user`).value.trim(),
    password:       document.getElementById(`${p}-pass`).value
  };
}

async function connTest() {
  setGlobal('busy', '접속 중');
  try {
    const d = await api('POST', '/api/connection/test', getServerCfg('x'));
    log('conn-log', [
      { c: d.success ? 'ok' : 'err', t: (d.success ? '✅ ' : '❌ ') + d.message },
      ...(d.sessionId ? [{ c: 'inf', t: '   SessionID : ' + d.sessionId }] : []),
      ...(d.policyUri ? [{ c: 'inf', t: '   Policy    : ' + d.policyUri.split('/').pop() }] : [])
    ]);
    setGlobal(d.success ? 'ok' : 'err', d.success ? '연결 성공' : '연결 실패');
  } catch (e) {
    log('conn-log', [{ c: 'err', t: '❌ ' + e.message }]);
    setGlobal('err', '오류');
  }
}

async function connRead() {
  const nodeId = document.getElementById('x-node').value.trim();
  if (!nodeId) return;
  setGlobal('busy', '태그 읽기');
  try {
    const d = await api('POST', '/api/connection/read', {
      serverConfig: getServerCfg('x'), nodeId
    });
    const box = document.getElementById('tag-box');
    box.classList.remove('hidden');
    if (d.success) {
      box.innerHTML = `
        <div class="ll ok">✅ 읽기 성공</div>
        <div class="ll"><span class="mut">NodeID  : </span><span style="color:var(--t0)">${esc(d.nodeId)}</span></div>
        <div class="ll"><span class="mut">Value   : </span><span class="val">${esc(d.value ?? 'null')}</span></div>
        <div class="ll"><span class="mut">Status  : </span><span class="ok">${esc(d.statusCode)}</span></div>
        <div class="ll"><span class="mut">Time    : </span><span class="inf">${d.timestamp ? new Date(d.timestamp).toLocaleString('ko-KR') : '-'}</span></div>
      `;
      setGlobal('ok', '읽기 완료');
    } else {
      box.innerHTML = `<div class="ll err">❌ ${esc(d.error || '읽기 실패')}</div>`;
      setGlobal('err', '읽기 실패');
    }
  } catch (e) {
    setGlobal('err', '오류');
  }
}

async function connBrowse() {
  setGlobal('busy', '노드 탐색');
  try {
    const d = await api('POST', '/api/connection/browse', {
      serverConfig: getServerCfg('x'), startNodeId: null
    });
    const wrap = document.getElementById('browse-wrap');
    wrap.classList.remove('hidden');
    if (d.success && d.nodes?.length) {
      wrap.innerHTML =
        `<div style="font-family:var(--fm);font-size:11px;color:var(--t2);margin-bottom:12px">탐색 결과: ${d.nodes.length}개 노드 (클릭하면 태그 입력란에 복사)</div>` +
        d.nodes.map(n => `
          <div class="bnode" onclick="document.getElementById('x-node').value='${esc(n.nodeId)}'">
            <span>${n.nodeClass === 'Object' ? '📂' : '📌'}</span>
            <span style="color:var(--t0)">${esc(n.displayName)}</span>
            <span class="bclass">${esc(n.nodeClass)}</span>
            <span class="bnid">${esc(n.nodeId)}</span>
          </div>
        `).join('');
      setGlobal('ok', '탐색 완료');
    } else {
      wrap.innerHTML = `<div style="color:var(--t2);font-size:12px">${esc(d.error || '노드 없음')}</div>`;
      setGlobal('err', '탐색 실패');
    }
  } catch (e) {
    setGlobal('err', '오류');
  }
}

/* ─────────────────────────────────────────────────────────────
   03  데이터 크롤링
───────────────────────────────────────────────────────────── */
async function crawlStart() {
  const nodeIds = document.getElementById('w-nodes').value
    .trim().split('\n').map(s => s.trim()).filter(Boolean);
  if (!nodeIds.length) { alert('노드 ID를 입력하세요.'); return; }

  const cfg = {
    serverHostName: document.getElementById('w-server').value.trim(),
    port:           parseInt(document.getElementById('w-port').value) || 4840,
    clientHostName: document.getElementById('w-client').value.trim(),
    userName:       document.getElementById('w-user').value.trim(),
    password:       document.getElementById('w-pass').value
  };
  const intervalSeconds = parseInt(document.getElementById('w-interval').value) || 1;
  const durationSeconds = parseInt(document.getElementById('w-duration').value) || 30;

  const btn = document.getElementById('crawl-btn');
  btn.disabled = true;
  btn.textContent = '⏳ 수집 중...';

  const prog = document.getElementById('crawl-prog');
  prog.classList.remove('hidden');
  document.getElementById('crawl-bar').style.width = '0%';
  document.getElementById('crawl-ptxt').textContent = '서버 연결 중...';
  document.getElementById('crawl-cnt').textContent  = '0 건';
  setGlobal('busy', '크롤링 중');

  // 백엔드는 동기식이므로 프런트에서 타이머로 UI 진행 표시
  let fake = 0;
  const ticker = setInterval(() => {
    fake = Math.min(fake + (100 / durationSeconds) * .5, 94);
    document.getElementById('crawl-bar').style.width = fake + '%';
    document.getElementById('crawl-ptxt').textContent = `수집 중... ${Math.round(fake)}%`;
  }, 500);

  try {
    const d = await api('POST', '/api/crawl/start', {
      serverConfig: cfg, nodeIds, intervalSeconds, durationSeconds
    });
    clearInterval(ticker);
    document.getElementById('crawl-bar').style.width = '100%';
    document.getElementById('crawl-ptxt').textContent = '수집 완료';
    document.getElementById('crawl-cnt').textContent  = d.totalRecords + ' 건';

    log('crawl-log', [
      { c: 'ok',  t: `✅ 크롤링 완료 — ${d.totalRecords}개 레코드` },
      { c: 'inf', t: `   Session : ${d.sessionId}` },
      { c: 'inf', t: `   CSV     : ${d.csvPath}` },
      { c: 'mut', t: '--- 미리보기 ---' },
      ...(d.preview || []).map(r => ({
        c: 'val',
        t: `   [${new Date(r.collectedAt).toLocaleTimeString('ko-KR')}] ${r.nodeId} = ${r.value} (${r.statusCode})`
      }))
    ]);
    setGlobal('ok', '크롤링 완료');
  } catch (e) {
    clearInterval(ticker);
    log('crawl-log', [{ c: 'err', t: '❌ ' + e.message }]);
    setGlobal('err', '오류');
  } finally {
    btn.disabled = false;
    btn.innerHTML = '📡 크롤링 시작';
  }
}

/* ─────────────────────────────────────────────────────────────
   03-B  노드맵 수집
───────────────────────────────────────────────────────────── */
async function nodeMapCrawl() {
  const maxDepth = parseInt(document.getElementById('nm-depth').value) || 10;
  const cfg = {
    serverHostName: document.getElementById('w-server').value.trim(),
    port:           parseInt(document.getElementById('w-port').value) || 4840,
    clientHostName: document.getElementById('w-client').value.trim(),
    userName:       document.getElementById('w-user').value.trim(),
    password:       document.getElementById('w-pass').value
  };

  const btn = document.getElementById('nm-btn');
  btn.disabled = true;
  btn.textContent = '⏳ 탐색 중...';

  const prog = document.getElementById('nm-prog');
  prog.classList.remove('hidden');
  document.getElementById('nm-bar').style.width = '0%';
  document.getElementById('nm-ptxt').textContent = '서버 연결 중...';
  document.getElementById('nm-cnt').textContent  = '';
  setGlobal('busy', '노드맵 수집 중');

  // 완료 시점을 알 수 없으므로 완만하게 90%까지 증가
  let fake = 0;
  const ticker = setInterval(() => {
    if (fake < 90) {
      fake = Math.min(fake + 0.25, 90);
      document.getElementById('nm-bar').style.width = fake + '%';
      document.getElementById('nm-ptxt').textContent = `노드 탐색 중... ${Math.round(fake)}%`;
    }
  }, 500);

  try {
    const d = await api('POST', '/api/crawl/nodemap', { serverConfig: cfg, maxDepth });
    clearInterval(ticker);
    document.getElementById('nm-bar').style.width = '100%';
    document.getElementById('nm-ptxt').textContent = d.success ? '탐색 완료' : '탐색 실패';
    document.getElementById('nm-cnt').textContent  = d.success ? d.totalCount.toLocaleString() + '개 노드' : '';

    log('nm-log', d.success ? [
      { c: 'ok',  t: `✅ 노드맵 수집 완료 — ${d.totalCount.toLocaleString()}개 노드` },
      { c: 'inf', t: `   저장 경로  : ${d.csvPath}` },
      { c: 'mut', t: `   04 DB 저장 탭에서 raw_node_map 테이블에 적재할 수 있습니다.` }
    ] : [
      { c: 'err', t: `❌ ${d.error || '탐색 실패'}` }
    ]);
    setGlobal(d.success ? 'ok' : 'err', d.success ? `노드 ${d.totalCount}개` : '실패');
  } catch (e) {
    clearInterval(ticker);
    log('nm-log', [{ c: 'err', t: '❌ ' + e.message }]);
    setGlobal('err', '오류');
  } finally {
    btn.disabled = false;
    btn.textContent = '🗺 전체 노드맵 수집';
  }
}

/* ─────────────────────────────────────────────────────────────
   04  DB 저장
───────────────────────────────────────────────────────────── */
async function dbLoadFiles() {
  try {
    const d = await api('GET', '/api/database/files');
    const list = document.getElementById('file-list');
    if (d.files?.length) {
      list.innerHTML = d.files.map(f => `
        <div class="fitem" onclick="selectFile('${esc(f)}',this)">
          <span>📄</span><span>${esc(f)}</span>
        </div>
      `).join('');
    } else {
      list.innerHTML = '<span class="placeholder">CSV 파일이 없습니다</span>';
    }
  } catch (e) { alert('목록 로드 실패: ' + e.message); }
}

function selectFile(name, el) {
  document.querySelectorAll('.fitem').forEach(i => i.classList.remove('sel'));
  el.classList.add('sel');
  document.getElementById('sel-csv').value = name;
}

async function dbImport() {
  const fileName = document.getElementById('sel-csv').value.trim();
  if (!fileName) { alert('파일을 선택하세요.'); return; }
  // experion_ 으로 시작하면 일반 크롤 CSV, 그 외는 노드맵 CSV (서버명_timestamp.csv)
  const serverHostName = fileName.startsWith('experion_') ? '' : fileName.split('_')[0];
  const truncate = document.querySelector('input[name="import-mode"]:checked').value === 'truncate';
  if (truncate && !confirm('기존 데이터를 모두 삭제하고 새로 저장합니다.\n계속하시겠습니까?')) return;
  setGlobal('busy', 'DB 저장 중');
  try {
    const d = await api('POST', '/api/database/import', { fileName, serverHostName, truncate });
    const isNodeMap = !!serverHostName;
    const lines = [
      { c: d.success ? 'ok' : 'err', t: (d.success ? '✅ ' : '❌ ') + d.message }
    ];
    if (d.success && isNodeMap) {
      lines.push({ c: 'inf', t: `   raw_node_map  : ${d.count}건` });
      lines.push({ c: 'inf', t: `   node_map_master: ${d.masterCount}건` });
    } else if (d.success) {
      lines.push({ c: 'inf', t: `   저장된 레코드 : ${d.count}건` });
    }
    log('db-log', lines);
    setGlobal(d.success ? 'ok' : 'err', d.success ? 'DB 저장 완료' : '저장 실패');
    if (d.success) await dbQuery();
  } catch (e) {
    log('db-log', [{ c: 'err', t: '❌ ' + e.message }]);
    setGlobal('err', '오류');
  }
}

async function dbQuery() {
  const limit = parseInt(document.getElementById('db-limit').value) || 100;
  setGlobal('busy', 'DB 조회');
  try {
    const d = await api('GET', `/api/database/records?limit=${limit}`);

    const stats = document.getElementById('db-stats');
    stats.classList.remove('hidden');
    stats.innerHTML = `
      <div class="stat"><div class="sv">${d.total.toLocaleString()}</div><div class="sk">전체 레코드</div></div>
      <div class="stat"><div class="sv">${(d.records||[]).length}</div><div class="sk">현재 조회</div></div>
    `;

    const tbl = document.getElementById('db-table');
    tbl.classList.remove('hidden');
    if (d.records?.length) {
      tbl.innerHTML = `
        <table>
          <thead>
            <tr>
              <th>ID</th>
              <th>Node ID</th>
              <th>Value</th>
              <th>Status</th>
              <th>수집 시각</th>
              <th>Session</th>
            </tr>
          </thead>
          <tbody>
            ${d.records.map(r => `
              <tr>
                <td class="mut">${r.id}</td>
                <td style="font-size:11px">${esc(r.nodeId)}</td>
                <td class="val">${esc(r.value ?? 'null')}</td>
                <td><span class="${r.statusCode === 'Good' ? 'bg' : 'br'}">${esc(r.statusCode)}</span></td>
                <td>${new Date(r.collectedAt).toLocaleString('ko-KR')}</td>
                <td class="mut" style="font-size:10px">${esc(r.sessionId ?? '-')}</td>
              </tr>
            `).join('')}
          </tbody>
        </table>
      `;
    } else {
      tbl.innerHTML = '<div style="padding:20px;color:var(--t2)">저장된 레코드가 없습니다.</div>';
    }
    setGlobal('ok', `${d.total}건`);
  } catch (e) { setGlobal('err', '조회 실패'); }
}

/* ─────────────────────────────────────────────────────────────
   05  노드맵 대시보드
───────────────────────────────────────────────────────────── */
let _nmOffset = 0;
let _nmTotal  = 0;
let _nmLimit  = 100;

// 이름 드롭다운 옵션만 필요할 때 수동으로 불러오는 함수 (버튼 클릭 시)
async function nmLoadNames() {
  try {
    const n = await api('GET', '/api/nodemap/names');
    const nameOpts = '<option value="">— 선택 안 함 —</option>' +
      (n.names||[]).map(nm => `<option value="${esc(nm)}">${esc(nm)}</option>`).join('');
    ['nf-name-1','nf-name-2','nf-name-3','nf-name-4'].forEach(id => {
      const el = document.getElementById(id);
      const cur = el.value;
      el.innerHTML = nameOpts;
      if (cur) el.value = cur;
    });
  } catch (e) { console.error('nmLoadNames:', e); }
}

async function nmQuery(offset) {
  _nmOffset = offset ?? 0;
  _nmLimit  = parseInt(document.getElementById('nf-limit').value) || 100;

  const params = new URLSearchParams();
  const lvMin  = document.getElementById('nf-lv-min').value.trim();
  const lvMax  = document.getElementById('nf-lv-max').value.trim();
  const cls    = document.getElementById('nf-class').value;
  const nid    = document.getElementById('nf-nid').value.trim();
  const dtype  = document.getElementById('nf-dtype').value.trim();
  const selNames = ['nf-name-1','nf-name-2','nf-name-3','nf-name-4']
    .map(id => document.getElementById(id).value)
    .filter(Boolean);

  if (lvMin)  params.set('minLevel',  lvMin);
  if (lvMax)  params.set('maxLevel',  lvMax);
  if (cls)    params.set('nodeClass', cls);
  selNames.forEach(nm => params.append('names', nm));
  if (nid)    params.set('nodeId',    nid);
  if (dtype)  params.set('dataType',  dtype);
  params.set('limit',  _nmLimit);
  params.set('offset', _nmOffset);

  setGlobal('busy', '조회 중');
  try {
    const d = await api('GET', `/api/nodemap/query?${params}`);
    _nmTotal = d.total;

    // 결과 바
    const bar = document.getElementById('nm-result-bar');
    bar.classList.remove('hidden');
    const from = _nmOffset + 1;
    const to   = Math.min(_nmOffset + _nmLimit, _nmTotal);
    document.getElementById('nm-result-info').textContent =
      `총 ${_nmTotal.toLocaleString()}건 중 ${from.toLocaleString()}–${to.toLocaleString()}건`;

    const totalPages = Math.ceil(_nmTotal / _nmLimit) || 1;
    const curPage    = Math.floor(_nmOffset / _nmLimit) + 1;
    document.getElementById('nm-pg-info').textContent = `${curPage} / ${totalPages} 페이지`;
    document.getElementById('nm-pg-prev').disabled = _nmOffset === 0;
    document.getElementById('nm-pg-next').disabled = to >= _nmTotal;

    // 테이블 렌더
    const tbl = document.getElementById('nm-table');
    tbl.classList.remove('hidden');
    if (d.items?.length) {
      tbl.innerHTML = `
        <table>
          <thead>
            <tr>
              <th>ID</th><th>Level</th><th>Class</th>
              <th>Name</th><th>Node ID</th><th>Data Type</th>
            </tr>
          </thead>
          <tbody>
            ${d.items.map(r => `
              <tr>
                <td class="mut" style="font-size:11px">${r.id}</td>
                <td class="mono" style="text-align:center">${r.level}</td>
                <td><span class="nm-cls nm-cls-${(r.class||'').toLowerCase()}">${esc(r.class)}</span></td>
                <td>${esc(r.name)}</td>
                <td class="mut" style="font-size:11px;font-family:var(--fm)">${esc(r.nodeId)}</td>
                <td><span class="nm-dtype">${esc(r.dataType)}</span></td>
              </tr>
            `).join('')}
          </tbody>
        </table>
      `;
    } else {
      tbl.innerHTML = '<div style="padding:20px;color:var(--t2)">조건에 맞는 노드가 없습니다.</div>';
    }
    setGlobal('ok', `${_nmTotal.toLocaleString()}건`);
  } catch (e) {
    setGlobal('err', '조회 실패');
    console.error('nmQuery:', e);
  }
}

function nmPrev() {
  if (_nmOffset > 0) nmQuery(Math.max(0, _nmOffset - _nmLimit));
}
function nmNext() {
  if (_nmOffset + _nmLimit < _nmTotal) nmQuery(_nmOffset + _nmLimit);
}
function nmReset() {
  document.getElementById('nf-lv-min').value = '';
  document.getElementById('nf-lv-max').value = '';
  document.getElementById('nf-class').value  = '';
  document.getElementById('nf-nid').value    = '';
  document.getElementById('nf-dtype').value  = '';
  document.getElementById('nf-limit').value  = '100';
  ['nf-name-1','nf-name-2','nf-name-3','nf-name-4'].forEach(id => {
    document.getElementById(id).value = '';
  });
  // 초기화 후 자동 조회 없음
  document.getElementById('nm-result-bar').classList.add('hidden');
  document.getElementById('nm-table').classList.add('hidden');
}

/* ─────────────────────────────────────────────────────────────
   06  포인트빌더
───────────────────────────────────────────────────────────── */
const PB_NAME_IDS = ['pb-n1','pb-n2','pb-n3','pb-n4','pb-n5','pb-n6','pb-n7','pb-n8'];
const PB_DT_IDS   = ['pb-dt1','pb-dt2'];

async function pbLoad() {
  const logEl = document.getElementById('pb-build-log');
  logEl.classList.remove('hidden');
  logEl.innerHTML = '<div class="ll inf">옵션 불러오기 시작...</div>';
  setGlobal('busy', '옵션 불러오는 중');

  try {
    const n = await api('GET', '/api/nodemap/names');
    const names = n.names || [];
    const nameOpts = '<option value="">— 선택 안 함 —</option>' +
      names.map(nm => `<option value="${esc(nm)}">${esc(nm)}</option>`).join('');
    PB_NAME_IDS.forEach(id => {
      const el = document.getElementById(id);
      const cur = el.value;
      el.innerHTML = nameOpts;
      if (cur) el.value = cur;
    });
    logEl.innerHTML += `<div class="ll ok">✅ ${names.length}개 이름 로드 완료</div>`;
    setGlobal('ok', `${names.length}개 로드`);
  } catch (e) {
    logEl.innerHTML += `<div class="ll err">❌ 옵션 불러오기 실패: ${esc(e.message)}</div>`;
    setGlobal('err', '로드 실패');
    console.error('pbLoad:', e);
  }
}

async function pbRefresh() {
  try {
    const d = await api('GET', '/api/pointbuilder/points');
    document.getElementById('pb-count').textContent = `(${d.count}개)`;
    pbRender(d.points || []);
    rtStatus();
  } catch (e) { console.error('pbRefresh:', e); }
}

function pbRender(points) {
  const tbl = document.getElementById('pb-table');
  if (!points.length) {
    tbl.innerHTML = '<div style="padding:20px;color:var(--t2)">포인트가 없습니다. 위에서 테이블을 작성하세요.</div>';
    return;
  }
  tbl.innerHTML = `
    <table>
      <thead>
        <tr>
          <th>ID</th><th>TagName</th><th>Node ID</th>
          <th>LiveValue</th><th>Timestamp</th><th></th>
        </tr>
      </thead>
      <tbody>
        ${points.map(p => `
          <tr>
            <td class="mut">${p.id}</td>
            <td style="font-weight:600">${esc(p.tagName)}</td>
            <td class="mut" style="font-size:11px;font-family:var(--fm)">${esc(p.nodeId)}</td>
            <td class="val">${p.liveValue != null ? esc(p.liveValue) : '<span style="color:var(--t3)">—</span>'}</td>
            <td class="mut" style="font-size:11px">${p.liveValue != null ? new Date(p.timestamp).toLocaleString('ko-KR') : '—'}</td>
            <td><button class="btn-sm btn-b" style="color:var(--red,#e55)" onclick="pbDelete(${p.id})">✕</button></td>
          </tr>
        `).join('')}
      </tbody>
    </table>
  `;
}

async function pbBuild() {
  const names = PB_NAME_IDS.map(id => document.getElementById(id).value).filter(Boolean);
  const dataTypes = PB_DT_IDS.map(id => document.getElementById(id).value).filter(Boolean);

  setGlobal('busy', '포인트 빌드 중');
  try {
    const d = await api('POST', '/api/pointbuilder/build', { names, dataTypes });
    log('pb-build-log', [{ c: d.success ? 'ok' : 'err', t: (d.success ? '✅ ' : '❌ ') + d.message }]);
    setGlobal(d.success ? 'ok' : 'err', d.success ? `${d.count}개 포인트` : '빌드 실패');
    if (d.success) await pbRefresh();
  } catch (e) {
    log('pb-build-log', [{ c: 'err', t: '❌ ' + e.message }]);
    setGlobal('err', '오류');
  }
}

async function pbAddManual() {
  const nodeId = document.getElementById('pb-manual-nid').value.trim();
  if (!nodeId) { alert('Node ID를 입력하세요.'); return; }
  try {
    const d = await api('POST', '/api/pointbuilder/add', { nodeId });
    log('pb-manual-log', [{ c: d.success ? 'ok' : 'err', t: d.success
      ? `✅ 추가됨: ${d.point?.tagName} (${d.point?.nodeId})`
      : '❌ 추가 실패' }]);
    if (d.success) {
      document.getElementById('pb-manual-nid').value = '';
      await pbRefresh();
    }
  } catch (e) {
    log('pb-manual-log', [{ c: 'err', t: '❌ ' + e.message }]);
  }
}

async function pbDelete(id) {
  if (!confirm(`포인트 #${id}를 삭제하시겠습니까?`)) return;
  try {
    const d = await api('DELETE', `/api/pointbuilder/${id}`);
    if (d.success) await pbRefresh();
    else alert('삭제 실패: ' + d.message);
  } catch (e) { alert('삭제 오류: ' + e.message); }
}

/* ── 실시간 구독 제어 ────────────────────────────────────────── */
async function rtStart() {
  const body = {
    serverHostName: document.getElementById('pb-rt-ip').value.trim(),
    port:           parseInt(document.getElementById('pb-rt-port').value) || 4840,
    clientHostName: document.getElementById('pb-rt-client').value.trim(),
    userName:       document.getElementById('pb-rt-user').value.trim(),
    password:       document.getElementById('pb-rt-pw').value
  };
  setGlobal('busy', '구독 시작 중');
  try {
    const d = await api('POST', '/api/realtime/start', body);
    log('pb-rt-status', [{ c: 'ok', t: '▶ ' + d.message }]);
    setGlobal('ok', '구독 중');
  } catch (e) {
    log('pb-rt-status', [{ c: 'err', t: '❌ ' + e.message }]);
    setGlobal('err', '오류');
  }
}

async function rtStop() {
  setGlobal('busy', '구독 중지 중');
  try {
    const d = await api('POST', '/api/realtime/stop');
    log('pb-rt-status', [{ c: 'inf', t: '■ ' + d.message }]);
    setGlobal('ok', '중지됨');
  } catch (e) { log('pb-rt-status', [{ c: 'err', t: '❌ ' + e.message }]); }
}

async function rtStatus() {
  try {
    const d = await api('GET', '/api/realtime/status');
    const logEl = document.getElementById('pb-rt-status');
    logEl.classList.remove('hidden');
    logEl.innerHTML = `<div class="ll ${d.running ? 'ok' : 'inf'}">
      ${d.running ? '▶' : '■'} ${esc(d.message)} (구독 ${d.subscribedCount}개)
    </div>`;
  } catch (e) { /* 무시 */ }
}

/* ─────────────────────────────────────────────────────────────
   07  이력 조회
───────────────────────────────────────────────────────────── */
const HIST_TAG_IDS = ['hf-t1','hf-t2','hf-t3','hf-t4','hf-t5','hf-t6','hf-t7','hf-t8'];

// 상태 표시기 업데이트 함수
function histUpdateStatus(state, message) {
  const el = document.getElementById('hist-load-status');
  if (!el) return;
  
  // 상태 클래스 제거 후 재설정
  el.classList.remove('loading', 'success', 'error');
  
  if (state) {
    el.classList.add(state);
  }
  
  // 아이콘/텍스트 업데이트 - .status-dot span은 유지
  const icons = { loading: '⏳', success: '✅', error: '❌' };
  const icon = icons[state] || '';
  
  // .status-dot span 찾기
  const dot = el.querySelector('.status-dot');
  
  // 텍스트 노드 업데이트 (첫 번째 텍스트 노드)
  const textNode = Array.from(el.childNodes).find(n => n.nodeType === Node.TEXT_NODE);
  if (textNode) {
    textNode.textContent = icon ? `${icon} ${message}` : message;
  }
}

// "▼ 옵션 불러오기" 버튼 클릭 시에만 호출 — 탭 진입 시 자동 호출 없음
async function histLoad() {
  const el = document.getElementById('hist-load-status');
  if (!el) return;
  
  // 로딩 상태 표시
  histUpdateStatus('loading', '조회 중...');
  
  try {
    const startTime = Date.now();
    const d = await api('GET', '/api/history/tagnames');
    const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
    
    const tagNames = d.tagNames || [];
    
    if (tagNames.length === 0) {
      histUpdateStatus('error', '조회 데이터 없음 (0개)');
    } else {
      const opts = '<option value="">— 선택 안 함 —</option>' +
        tagNames.map(t => `<option value="${esc(t)}">${esc(t)}</option>`).join('');
      
      // 기존 선택값 보존
      const selectedValues = HIST_TAG_IDS.map(id => document.getElementById(id).value);
      
      // DOM 업데이트를 requestAnimationFrame으로 지연하여 레이아웃 충돌 방지
      requestAnimationFrame(() => {
        HIST_TAG_IDS.forEach((id, index) => {
          const sel = document.getElementById(id);
          sel.innerHTML = opts;
          if (selectedValues[index]) sel.value = selectedValues[index];
        });
      });
      
      histUpdateStatus('success', `옵션 조회 완료 (${tagNames.length}개, ${elapsed}초)`);
    }
  } catch (e) {
    console.error('histLoad:', e);
    histUpdateStatus('error', `조회 실패: ${e.message}`);
  }
}

async function histQuery() {
  const tags  = HIST_TAG_IDS.map(id => document.getElementById(id).value).filter(Boolean);
  const from  = document.getElementById('hf-from').value;
  const to    = document.getElementById('hf-to').value;
  const limit = parseInt(document.getElementById('hf-limit').value) || 500;

  if (!tags.length) {
    histShowStatus('err', '❌', '태그를 최소 1개 선택하세요', ' 태그 선택란에서 태그를 선택해주세요.');
    return;
  }

  const params = new URLSearchParams();
  tags.forEach(t => params.append('tagNames', t));
  if (from) params.set('from', new Date(from).toISOString());
  if (to)   params.set('to',   new Date(to).toISOString());
  params.set('limit', limit);

  // 상태 표시: 조회 시작
  histShowStatus('busy', '⏳', '조회 중...', ` ${tags.length}개 태그, 제한: ${limit}행`);

  try {
    const d = await api('GET', `/api/history/query?${params}`);
    const rows  = d.rows || [];
    const tNames = d.tagNames || [];

    // 테이블 요소 미리 가져오기
    const tbl = document.getElementById('hist-table');
    tbl.classList.remove('hidden');

    // 상태 표시: 조회 성공
    const info = document.getElementById('hist-result-info');
    info.classList.remove('hidden');
    info.textContent = `총 ${rows.length.toLocaleString()}행 × ${tNames.length}개 태그`;

    if (!rows.length) {
      histShowStatus('ok', '✅', '조회 완료 (0건)', ` 조건에 맞는 데이터가 없습니다. | 태그: ${tNames.join(', ') || '전체'}`);
      tbl.innerHTML = '<div style="padding:20px;color:var(--t2)">조건에 맞는 이력이 없습니다.</div>';
      setGlobal('ok', '0건');
      return;
    }

    tbl.innerHTML = `
      <table>
        <thead>
          <tr>
            <th>시각</th>
            ${tNames.map(t => `<th>${esc(t)}</th>`).join('')}
          </tr>
        </thead>
        <tbody>
          ${rows.map(r => `
            <tr>
              <td class="mut" style="white-space:nowrap">${new Date(r.recordedAt).toLocaleString('ko-KR')}</td>
              ${tNames.map(t => `<td class="val">${r.values?.[t] != null ? esc(r.values[t]) : '<span style="color:var(--t3)">—</span>'}</td>`).join('')}
            </tr>
          `).join('')}
        </tbody>
      </table>
    `;
    histShowStatus('ok', '✅', `조회 완료 (${rows.length}건)`, ` 태그: ${tNames.join(', ')} | 시각 범위: ${from || '전체'} ~ ${to || '전체'}`);
    setGlobal('ok', `${rows.length}건`);
  } catch (e) {
    // 상태 표시: 조회 실패 (상세 에러 정보 포함)
    const errDetail = e.message || '알 수 없는 오류';
    histShowStatus('err', '❌', '조회 실패', ` ${errDetail}\n\n컨솔에서 상세 오류를 확인하세요.`);
    setGlobal('err', '조회 실패');
    console.error('histQuery 오류:', e);
    console.error('요청 파라미터:', { tags, from, to, limit });
  }
}

/**
 * 이력 조회 상태 표시 창 업데이트
 * @param {string} state - 'pending' | 'busy' | 'ok' | 'err'
 * @param {string} icon - 표시할 아이콘
 * @param {string} text - 메인 상태 텍스트
 * @param {string} detail - 상세 설명
 */
function histShowStatus(state, icon, text, detail) {
  const box    = document.getElementById('hist-status-box');
  const iconEl = document.getElementById('hist-status-icon');
  const textEl = document.getElementById('hist-status-text');
  const detEl  = document.getElementById('hist-status-detail');

  box.classList.remove('hidden', 'pending', 'busy', 'ok', 'err');
  box.classList.add(state);
  iconEl.textContent = icon;
  textEl.textContent = text;
  detEl.textContent  = detail || '';
}

function histReset() {
  HIST_TAG_IDS.forEach(id => { document.getElementById(id).value = ''; });
  dtClearField('from');
  dtClearField('to');
  document.getElementById('hf-limit').value = '500';
  document.getElementById('hist-result-info').classList.add('hidden');
  document.getElementById('hist-table').classList.add('hidden');
  
  // 상태 표시 창 초기화
  histShowStatus('pending', '⏸', '대기 중', '조회 조건을 설정하고 조회 버튼을 눌러주세요.');
}

/* ─────────────────────────────────────────────────────────────
    07-2  하이퍼테이블 관리
 ───────────────────────────────────────────────────────────── */

/**
 * 하이퍼테이블 상태 불러오기
 */
async function htLoadStatus() {
  try {
    const d = await api('GET', '/api/experion/hypertable/status');
    
    // 상태 표시 창 업데이트
    const box = document.getElementById('ht-status-box');
    const iconEl = document.getElementById('ht-status-icon');
    const textEl = document.getElementById('ht-status-text');
    const detEl = document.getElementById('ht-status-detail');
    
    box.classList.remove('hidden', 'pending', 'busy', 'ok', 'err');
    
    // 정보 패널 업데이트
    const infoPanel = document.getElementById('ht-info-panel');
    document.getElementById('ht-info-table').textContent = d.tableName || '-';
    document.getElementById('ht-info-records').textContent = d.recordCount != null ? d.recordCount.toLocaleString() : '-';
    document.getElementById('ht-info-retention').textContent = d.hasRetentionPolicy ? '설정됨' : '미설정';
    document.getElementById('ht-info-compression').textContent = d.hasCompression ? '활성화됨' : '비활성화됨';
    
    if (d.isHypertable) {
      box.classList.add('ok');
      iconEl.textContent = '✅';
      textEl.textContent = '하이퍼테이블 활성화됨';
      detEl.textContent = d.statusMessage || 'history_table이 하이퍼테이블로 변환되었습니다.';
      infoPanel.classList.remove('hidden');
    } else {
      box.classList.add('pending');
      iconEl.textContent = '⚠️';
      textEl.textContent = '일반 테이블';
      detEl.textContent = d.statusMessage || 'history_table이 아직 하이퍼테이블로 변환되지 않았습니다.';
      infoPanel.classList.add('hidden');
    }
  } catch (e) {
    console.error('htLoadStatus 오류:', e);
    const box = document.getElementById('ht-status-box');
    const iconEl = document.getElementById('ht-status-icon');
    const textEl = document.getElementById('ht-status-text');
    const detEl = document.getElementById('ht-status-detail');
    
    box.classList.remove('hidden', 'pending', 'busy', 'ok', 'err');
    box.classList.add('err');
    iconEl.textContent = '❌';
    textEl.textContent = '상태 조회 실패';
    detEl.textContent = e.message || '알 수 없는 오류';
    document.getElementById('ht-info-panel').classList.add('hidden');
  }
}

/**
 * 하이퍼테이블 생성
 */
async function htCreate() {
  const tableName = document.getElementById('ht-table-name').value || 'history_table';
  const retentionEnabled = document.getElementById('ht-auto-retention').checked;
  const compressionEnabled = document.getElementById('ht-auto-compression').checked;
  const aggregateEnabled = document.getElementById('ht-auto-aggregate').checked;
  
  // 생성 전 확인
  const confirmMsg = retentionEnabled || compressionEnabled
    ? `다음 옵션으로 하이퍼테이블을 생성합니다:\n\n` +
      `- 테이블명: ${tableName}\n` +
      `- 보관 기간: ${retentionEnabled ? document.getElementById('ht-retention-period').value : '설정 안함'}\n` +
      `- 압축: ${compressionEnabled ? '활성화 (' + document.getElementById('ht-compression-period').value + ')' : '비활성화'}\n` +
      `- 연속 집계: ${aggregateEnabled ? '생성' : '생성 안함'}\n\n` +
      `계속 진행하시겠습니까?`
    : `기본 설정으로 하이퍼테이블을 생성합니다:\n\n` +
      `- 테이블명: ${tableName}\n` +
      `- 보관 기간: 설정 안함\n` +
      `- 압축: 설정 안함\n\n` +
      `계속 진행하시겠습니까?`;
  
  if (!confirm(confirmMsg)) return;
  
  try {
    // 상태 표시 창을 로딩 모드로
    const box = document.getElementById('ht-status-box');
    const iconEl = document.getElementById('ht-status-icon');
    const textEl = document.getElementById('ht-status-text');
    const detEl = document.getElementById('ht-status-detail');
    
    box.classList.remove('hidden', 'pending', 'ok', 'err');
    box.classList.add('busy');
    iconEl.textContent = '⏳';
    textEl.textContent = '생성 중...';
    detEl.textContent = '하이퍼테이블을 생성하고 있습니다. 잠시 기다려주세요.';
    document.getElementById('ht-info-panel').classList.add('hidden');
    
    const body = {
      tableName: tableName,
      timeColumn: 'recorded_at',
      timeInterval: '1 day',
      migrateData: true,
      setRetentionPolicy: retentionEnabled,
      retentionPeriod: retentionEnabled ? document.getElementById('ht-retention-period').value : undefined,
      enableCompression: compressionEnabled,
      compressionPeriod: compressionEnabled ? document.getElementById('ht-compression-period').value : undefined,
      createContinuousAggregate: aggregateEnabled
    };
    
    // 불필요한 필드는 제거
    Object.keys(body).forEach(key => {
      if (body[key] === undefined) delete body[key];
    });
    
    await api('POST', '/api/experion/hypertable/create', body);
    
    // 상태 표시: 성공
    box.classList.remove('busy');
    box.classList.add('ok');
    iconEl.textContent = '✅';
    textEl.textContent = '생성 완료';
    detEl.textContent = '하이퍼테이블이 성공적으로 생성되었습니다. 상태 새로고침 버튼을 눌러주세요.';
    
    // 상태 새로고침
    setTimeout(() => htLoadStatus(), 500);
  } catch (e) {
    console.error('htCreate 오류:', e);
    const box = document.getElementById('ht-status-box');
    const iconEl = document.getElementById('ht-status-icon');
    const textEl = document.getElementById('ht-status-text');
    const detEl = document.getElementById('ht-status-detail');
    
    box.classList.remove('busy', 'pending', 'ok', 'err');
    box.classList.add('err');
    iconEl.textContent = '❌';
    textEl.textContent = '생성 실패';
    detEl.textContent = e.message || '알 수 없는 오류';
  }
}

/**
 * 보관 기간 설정 토글
 */
function htToggleRetention() {
  const checked = document.getElementById('ht-auto-retention').checked;
  const panel = document.getElementById('ht-retention-panel');
  panel.classList.toggle('ht-hidden', !checked);
}

/**
 * 압축 설정 토글
 */
function htToggleCompression() {
  const checked = document.getElementById('ht-auto-compression').checked;
  const panel = document.getElementById('ht-compression-panel');
  panel.classList.toggle('ht-hidden', !checked);
}

/* ── Custom DateTime Picker ──────────────────────────────────── */
const _dtp = { target: null, year: 0, month: 0,
               selYear: null, selMonth: null, selDay: null,
               selHour: 0, selMin: 0 };

const _DTP_DAYS = ['일','월','화','수','목','금','토'];

function dtOpen(target) {
  _dtp.target = target;
  const hidden = document.getElementById(`hf-${target}`).value;
  const d = hidden ? new Date(hidden) : new Date();
  _dtp.year  = d.getFullYear();
  _dtp.month = d.getMonth();
  if (hidden && !isNaN(d)) {
    _dtp.selYear = d.getFullYear(); _dtp.selMonth = d.getMonth();
    _dtp.selDay  = d.getDate();
    _dtp.selHour = d.getHours();    _dtp.selMin   = d.getMinutes();
  } else {
    _dtp.selYear = null; _dtp.selMonth = null; _dtp.selDay = null;
    _dtp.selHour = 0;    _dtp.selMin   = 0;
  }
  document.getElementById('dt-hour').value = String(_dtp.selHour).padStart(2,'0');
  document.getElementById('dt-min').value  = String(_dtp.selMin).padStart(2,'0');
  dtRenderCal();

  // 팝업 위치 계산
  const popup   = document.getElementById('dt-popup');
  const display = document.getElementById(`dtp-${target}-display`);
  const rect    = display.getBoundingClientRect();
  popup.classList.remove('hidden');
  document.getElementById('dt-overlay').classList.remove('hidden');

  // 화면 아래 공간 부족하면 위쪽으로
  const spaceBelow = window.innerHeight - rect.bottom;
  if (spaceBelow < popup.offsetHeight + 8) {
    popup.style.top  = (rect.top - popup.offsetHeight - 4) + 'px';
  } else {
    popup.style.top  = (rect.bottom + 4) + 'px';
  }
  popup.style.left = Math.min(rect.left, window.innerWidth - 296) + 'px';
}

function dtRenderCal() {
  document.getElementById('dt-month-label').textContent =
    `${_dtp.year}년 ${_dtp.month + 1}월`;
  const first      = new Date(_dtp.year, _dtp.month, 1).getDay();
  const daysInMon  = new Date(_dtp.year, _dtp.month + 1, 0).getDate();
  const daysInPrev = new Date(_dtp.year, _dtp.month,     0).getDate();
  const today      = new Date();
  let html = _DTP_DAYS.map(d => `<div class="dt-dow">${d}</div>`).join('');

  for (let i = first - 1; i >= 0; i--)
    html += `<div class="dt-day other-month">${daysInPrev - i}</div>`;

  for (let d = 1; d <= daysInMon; d++) {
    let cls = 'dt-day';
    if (_dtp.year === today.getFullYear() && _dtp.month === today.getMonth() && d === today.getDate())
      cls += ' today';
    if (_dtp.selYear === _dtp.year && _dtp.selMonth === _dtp.month && _dtp.selDay === d)
      cls += ' selected';
    html += `<div class="${cls}" onclick="dtSelectDay(${d})">${d}</div>`;
  }
  const trailing = (first + daysInMon) % 7;
  for (let d = 1; d <= (trailing ? 7 - trailing : 0); d++)
    html += `<div class="dt-day other-month">${d}</div>`;

  document.getElementById('dt-cal-grid').innerHTML = html;
}

function dtSelectDay(day) {
  _dtp.selYear = _dtp.year; _dtp.selMonth = _dtp.month; _dtp.selDay = day;
  dtRenderCal();
}
function dtPrevMonth() {
  if (--_dtp.month < 0) { _dtp.month = 11; _dtp.year--; }
  dtRenderCal();
}
function dtNextMonth() {
  if (++_dtp.month > 11) { _dtp.month = 0; _dtp.year++; }
  dtRenderCal();
}
function dtAdjTime(part, delta) {
  if (part === 'h') {
    _dtp.selHour = ((_dtp.selHour + delta) + 24) % 24;
    document.getElementById('dt-hour').value = String(_dtp.selHour).padStart(2,'0');
  } else {
    _dtp.selMin = ((_dtp.selMin + delta) + 60) % 60;
    document.getElementById('dt-min').value  = String(_dtp.selMin).padStart(2,'0');
  }
}
function dtClampTime(part, el) {
  const max = part === 'h' ? 23 : 59;
  let v = parseInt(el.value);
  if (isNaN(v) || v < 0) v = 0;
  if (v > max) v = max;
  el.value = String(v).padStart(2,'0');
  if (part === 'h') _dtp.selHour = v; else _dtp.selMin = v;
}
function dtConfirm() {
  if (_dtp.selDay === null) { alert('날짜를 선택하세요.'); return; }
  _dtp.selHour = parseInt(document.getElementById('dt-hour').value) || 0;
  _dtp.selMin  = parseInt(document.getElementById('dt-min').value)  || 0;
  const p = n => String(n).padStart(2,'0');
  const val = `${_dtp.selYear}-${p(_dtp.selMonth+1)}-${p(_dtp.selDay)}T${p(_dtp.selHour)}:${p(_dtp.selMin)}`;
  document.getElementById(`hf-${_dtp.target}`).value = val;
  document.getElementById(`dtp-${_dtp.target}-display`).textContent =
    `${_dtp.selYear}-${p(_dtp.selMonth+1)}-${p(_dtp.selDay)} ${p(_dtp.selHour)}:${p(_dtp.selMin)}`;
  dtClose();
}
function dtClear()  { dtClearField(_dtp.target); dtClose(); }
function dtClearField(target) {
  if (!target) return;
  document.getElementById(`hf-${target}`).value = '';
  document.getElementById(`dtp-${target}-display`).textContent = '— 선택 안 함 —';
}
function dtCancel() { dtClose(); }
function dtClose()  {
  document.getElementById('dt-popup').classList.add('hidden');
  document.getElementById('dt-overlay').classList.add('hidden');
  _dtp.target = null;
}

/* ── OPC UA 서버 ─────────────────────────────────────────────── */

let _srvPollTimer = null;

async function srvLoad() {
  try {
    const s = await api('GET', '/api/opcserver/status');
    _srvRender(s);
  } catch (e) {
    srvLog([{ c:'err', t:`상태 조회 실패: ${e.message}` }]);
  }
}

async function srvStart() {
  srvLog([{ c:'info', t:'OPC UA 서버 시작 중...' }]);
  try {
    const r = await api('POST', '/api/opcserver/start');
    srvLog([{ c: r.success ? 'ok' : 'err', t: r.message }]);
    await srvLoad();
    _srvStartPoll();
  } catch (e) {
    srvLog([{ c:'err', t:`시작 실패: ${e.message}` }]);
  }
}

async function srvStop() {
  srvLog([{ c:'info', t:'OPC UA 서버 중지 중...' }]);
  try {
    const r = await api('POST', '/api/opcserver/stop');
    srvLog([{ c: r.success ? 'ok' : 'err', t: r.message }]);
    _srvStopPoll();
    await srvLoad();
  } catch (e) {
    srvLog([{ c:'err', t:`중지 실패: ${e.message}` }]);
  }
}

async function srvRebuild() {
  srvLog([{ c:'info', t:'주소 공간 재구성 중...' }]);
  try {
    const r = await api('POST', '/api/opcserver/rebuild');
    srvLog([{ c: r.success ? 'ok' : 'err', t: `재구성 완료 — ${r.nodeCount}개 노드` }]);
    await srvLoad();
  } catch (e) {
    srvLog([{ c:'err', t:`재구성 실패: ${e.message}` }]);
  }
}

function _srvRender(s) {
  const dot  = document.getElementById('srv-dot');
  const txt  = document.getElementById('srv-status-txt');
  const meta = document.getElementById('srv-meta');
  const ndot = document.getElementById('opcsvr-dot');

  if (s.running) {
    dot.className  = 'dot grn';
    txt.textContent = '● 실행 중';
    if (ndot) ndot.className = 'nb grn';
  } else {
    dot.className  = 'dot';
    txt.textContent = '○ 중지됨';
    if (ndot) ndot.className = 'nb';
  }

  const started = s.startedAt ? new Date(s.startedAt).toLocaleString('ko-KR') : '—';
  meta.innerHTML =
    `<span>엔드포인트: <b>${esc(s.endpointUrl || '—')}</b></span>` +
    `<span>접속 클라이언트: <b>${s.connectedClientCount}</b></span>` +
    `<span>노드 수: <b>${s.nodeCount}</b></span>` +
    `<span>시작 시각: <b>${started}</b></span>`;
}

function srvLog(lines) {
  log('srv-log', lines);
}

function _srvStartPoll() {
  _srvStopPoll();
  _srvPollTimer = setInterval(srvLoad, 5000);
}

function _srvStopPoll() {
  if (_srvPollTimer) { clearInterval(_srvPollTimer); _srvPollTimer = null; }
}

/* ── Text-to-SQL Dashboard ───────────────────────────────────── */

/**
 * t2sParse - 자연어 쿼리를 SQL로 변환
 */
async function t2sParse() {
  const input = document.getElementById('t2s-query').value.trim();
  if (!input) {
    alert('자연어 쿼리를 입력해주세요.');
    return;
  }

  const sqlTextarea = document.getElementById('t2s-sql');
  sqlTextarea.value = '변환 중...';
  sqlTextarea.disabled = true;

  try {
    const res = await api('POST', '/api/text-to-sql/parse', { input });
    if (res.success) {
      sqlTextarea.value = res.sql || 'SQL 생성 실패';
    } else {
      sqlTextarea.value = `오류: ${res.message || '알 수 없는 오류'}`;
    }
  } catch (err) {
    sqlTextarea.value = `연결 오류: ${err.message}`;
  } finally {
    sqlTextarea.disabled = false;
  }
}

/**
 * t2sExecute - SQL 실행
 */
async function t2sExecute() {
  const sql = document.getElementById('t2s-sql').value.trim();
  if (!sql) {
    alert('SQL을 입력해주세요.');
    return;
  }

  const limitInput = document.getElementById('t2s-limit');
  const limit = limitInput.value ? parseInt(limitInput.value) : 1000;

  const resultContainer = document.getElementById('t2s-results');
  resultContainer.innerHTML = '<div class="t2s-loading">실행 중...</div>';

  try {
    const res = await api('POST', '/api/text-to-sql/execute', { sql, limit });
    if (res.success) {
      t2sRenderTable(res);
    } else {
      resultContainer.innerHTML = `<div class="t2s-error">오류: ${res.message || '알 수 없는 오류'}</div>`;
    }
  } catch (err) {
    resultContainer.innerHTML = `<div class="t2s-error">연결 오류: ${err.message}</div>`;
  }
}

/**
 * t2sRenderTable - 쿼리 결과를 테이블로 렌더링
 */
function t2sRenderTable(result) {
  const container = document.getElementById('t2s-results');
  
  if (!result.rows || result.rows.length === 0) {
    container.innerHTML = '<div class="t2s-empty">결과가 없습니다.</div>';
    return;
  }

  const columns = Object.keys(result.rows[0]);
  let html = '<div class="t2s-result-info">총 <b>' + result.rowCount + '</b>개 결과</div>';
  html += '<table class="t2s-table">';
  
  // Header
  html += '<thead><tr>';
  columns.forEach(col => {
    html += `<th>${esc(col)}</th>`;
  });
  html += '</tr></thead>';
  
  // Body
  html += '<tbody>';
  result.rows.forEach(row => {
    html += '<tr>';
    columns.forEach(col => {
      const val = row[col];
      html += `<td>${val !== null && val !== undefined ? esc(String(val)) : '<span class="t2s-null">NULL</span>'}</td>`;
    });
    html += '</tr>';
  });
  html += '</tbody>';
  
  html += '</table>';
  container.innerHTML = html;
}

/**
 * t2sAnalyze - 태그 분석 (시계열 분석)
 */
async function t2sAnalyze() {
  const tagNames = document.getElementById('t2s-tags').value.trim();
  if (!tagNames) {
    alert('분석할 태그 이름을 입력해주세요.');
    return;
  }

  const interval = document.getElementById('t2s-interval').value;
  const limit = document.getElementById('t2s-limit-analyze').value || '100';
  
  const dateFrom = document.getElementById('t2s-date-from').value;
  const dateTo = document.getElementById('t2s-date-to').value;
  
  const resultContainer = document.getElementById('t2s-analysis-results');
  resultContainer.innerHTML = '<div class="t2s-loading">분석 중...</div>';

  try {
    const res = await api('POST', '/api/text-to-sql/analyze', {
      tagNames: tagNames.split(',').map(t => t.trim()).filter(t => t),
      interval: interval,
      limit: parseInt(limit),
      dateFrom: dateFrom || undefined,
      dateTo: dateTo || undefined
    });
    
    if (res.success) {
      t2sRenderAnalysis(res);
    } else {
      resultContainer.innerHTML = `<div class="t2s-error">오류: ${res.message || '알 수 없는 오류'}</div>`;
    }
  } catch (err) {
    resultContainer.innerHTML = `<div class="t2s-error">연결 오류: ${err.message}</div>`;
  }
}

/**
 * t2sRenderAnalysis - 분석 결과를 렌더링
 */
function t2sRenderAnalysis(result) {
  const container = document.getElementById('t2s-analysis-results');
  
  if (!result.tags || result.tags.length === 0) {
    container.innerHTML = '<div class="t2s-empty">분석 결과가 없습니다.</div>';
    return;
  }

  let html = '<div class="t2s-result-info">총 <b>' + result.tags.length + '</b>개 태그 분석</div>';
  html += '<div class="t2s-analysis-grid">';
  
  result.tags.forEach(tag => {
    html += '<div class="t2s-tag-card">';
    html += `<h4>${esc(tag.tagName)}</h4>`;
    html += '<div class="t2s-tag-stats">';
    html += `<div class="t2s-stat-row"><span>평균:</span><span class="t2s-value">${tag.mean?.toFixed(4) || 'N/A'}</span></div>`;
    html += `<div class="t2s-stat-row"><span>최대:</span><span class="t2s-value t2s-max">${tag.max?.toFixed(4) || 'N/A'}</span></div>`;
    html += `<div class="t2s-stat-row"><span>최소:</span><span class="t2s-value t2s-min">${tag.min?.toFixed(4) || 'N/A'}</span></div>`;
    html += `<div class="t2s-stat-row"><span>표준편차:</span><span class="t2s-value">${tag.stdDev?.toFixed(4) || 'N/A'}</span></div>`;
    html += '</div>';
    html += '</div>';
  });
  
  html += '</div>';
  container.innerHTML = html;
}

/**
 * t2sSetQuery - 제안 쿼리 설정
 */
function t2sSetQuery(query) {
  document.getElementById('t2s-query').value = query;
}

/* ── 초기 실행 ───────────────────────────────────────────────── */
certStatus();
