// 자동 분리: src/sidebarProvider.ts 4002-5116 (IIFE 본문) 에서 추출. 동작 동등.
// `${assets.derivedBase}` placeholder 는 panelHtml 에서 .replace() 로 실제 값 주입.
// 다음 세션에서 OfficeSnapshot 기반으로 단계적으로 잘라낼 예정.

const OFFICE_RUNTIME_JS_TEMPLATE = `
<script>(function(){
const base='\${assets.derivedBase}'; const stage=document.getElementById('stage');
const officeEl = stage && stage.closest ? stage.closest('.office') : null;
if(officeEl){
  officeEl.style.setProperty('--office-backdrop', 'url(\"'+base+'/office-backdrop-astra-v3.png\")');
  officeEl.classList.add('has-art');
}
let _stageScale = 1;
function _fitStage(){
  const shell = stage && stage.closest ? stage.closest('.office') : null;
  if(!stage || !shell) return;
  const sx = Math.max(.62, (shell.clientWidth - 28) / 720);
  const sy = Math.max(.62, (shell.clientHeight - 28) / 585);
  _stageScale = Math.min(1, sx, sy);
  stage.style.transform = 'scale(' + _stageScale + ')';
  stage.style.transformOrigin = 'center center';
}
window.addEventListener('resize', _fitStage);
setTimeout(_fitStage, 0);
// \u2500\u2500 \uB370\uC774\uD130 \uBAA8\uB378 \u2500\u2500
// stations: \uCC45\uC0C1 + \uCE90\uB9AD\uD130 \uC815\uC758 \uBC30\uC5F4 (let \u2014 \uCD94\uAC00/\uC81C\uAC70 \uAC00\uB2A5).
//   key       = \uC548\uC815\uC801 \uC2DD\uBCC4\uC790 (DOM dataset.role \uB85C\uB3C4 \uC0AC\uC6A9). \uC0AC\uC6A9\uC790\uAC00 \uC0C8\uB85C \uB9CC\uB4E0 \uCC45\uC0C1\uC740 \uC790\uB3D9 \uC0DD\uC131.
//   agentKey  = \uC774 \uCC45\uC0C1\uC5D0 \uB9E4\uD551\uB41C \uC5D0\uC774\uC804\uD2B8 ID (ceo/planner/researcher/...). \uC5C6\uC73C\uBA74 idle.
//   charRow   = \uC0AC\uC6A9\uD560 sprite row (0~7). \uCE90\uB9AD\uD130 \uC678\uBAA8/\uBC29\uD5A5.
//   deskSprite= \uCC45\uC0C1 PNG \uC774\uB984 (desk-main / desk-boss / desk-dark-mirror \uB4F1).
// objs: \uD504\uB78D \uC815\uC758 \uBC30\uC5F4 (let). user-add/remove.
// 기본 장면은 더 이상 "가짜 8인 사무실"을 깔지 않는다.
// 실제 roster 가 들어오면 그 인원만으로 장면을 짓고, roster 가 비면 빈 오피스 그대로 둔다.
const DEFAULT_STATIONS=[];
const LEGACY_SHOWROOM_POS={
 ceo:[304,84],planner:[60,228],researcher:[236,214],designer:[402,240],
 developer:[56,410],qa:[232,394],inspector:[408,420],support:[220,472],
};
// \uC5D0\uC774\uC804\uD2B8 ID alias \u2014 \uAC19\uC740 \uD398\uB974\uC18C\uB098\uC758 \uB2E4\uC591\uD55C \uD638\uCE6D\uC744 \uAC19\uC740 book agentKey \uB85C. (writer\u2192planner, editor\u2192designer, secretary\u2192support, business\u2192inspector)
const AGENT_ALIASES={writer:'planner',editor:'designer',secretary:'support',business:'inspector'};
// \uB9E4\uD551 dropdown \uC5D0 \uBCF4\uC5EC\uC904 \uC5D0\uC774\uC804\uD2B8 \uD6C4\uBCF4. agentKey \uAC00 unique \uD55C base set.
const AGENT_CHOICES=['','ceo','planner','researcher','designer','developer','qa','inspector','support'];
// \uCD94\uAC00 \uAC00\uB2A5\uD55C \uCC45\uC0C1 sprite \uD6C4\uBCF4 (assets/pixelOffice/derived \uC5D0 \uC788\uB294 desk-* PNG).
const DESK_SPRITE_CHOICES=['astra-desk-work','astra-desk-dark','astra-desk-exec'];
// \uCD94\uAC00 \uAC00\uB2A5\uD55C \uD504\uB78D sprite \uD6C4\uBCF4.
const PROP_SPRITE_CHOICES=['astra-storage-low','astra-bookshelf-slim','astra-plant-corner','astra-cooler-slim','astra-printer-compact','astra-chair-task'];
const SPRITE_LABELS={
  'astra-desk-work':'워크 데스크',
  'astra-desk-dark':'다크 데스크',
  'astra-desk-exec':'대표 데스크',
  'astra-storage-low':'로우 스토리지',
  'astra-bookshelf-slim':'북케이스',
  'astra-plant-corner':'코너 플랜트',
  'astra-cooler-slim':'정수기',
  'astra-printer-compact':'프린터',
  'astra-chair-task':'태스크 체어',
};
// 후면 배경 자체가 이미 충분히 강하므로, 목적 없는 프랍은 기본 장면에서 제거.
// 커스텀 편집 모드에선 여전히 사용자가 원하는 프랍을 직접 추가할 수 있다.
const DEFAULT_PROPS=[];

let stations=[]; // mutable, \uC2DC\uC791 \uC2DC default \uB610\uB294 saved layout \uB85C \uCC44\uC6C0.
let _hasSavedLayout=false;
let _layoutSource='default';
let _lastAutoLayoutSignature='';
let _latestSnapshot=null;
let __nextDeskN=100; // user-add \uCC45\uC0C1 id \uCE74\uC6B4\uD130 (default \uC640 \uCDA9\uB3CC \uD68C\uD53C \uC704\uD574 \uD070 \uC218\uC5D0\uC11C \uC2DC\uC791).
let __nextObjN=0;
const stationByKey={}; // \uBE60\uB978 lookup. stations \uBCC0\uACBD \uC2DC rebuild.
const __deskWrap={}; // role \u2192 desk DOM wrap.
const chars={}; // role \u2192 char DOM.
const anim={};   // role \u2192 animation state.
// 한 번 처리된 agentId 는 여기에 기록 → 사용자가 그 desk 를 지워도 자동 재생성 안 함.
const _autoDeskedFor = new Set();

function png(name){return base+'/'+name+'.png'}
function _spriteLabel(name){ return SPRITE_LABELS[name] || name; }

function _rebuildStationIndex(){
  Object.keys(stationByKey).forEach(k=>delete stationByKey[k]);
  stations.forEach(st=>{ stationByKey[st.key]=st; });
}
function _safeDeskKey(raw){
  return 'pipe_' + String(raw || 'agent').toLowerCase().replace(/[^a-z0-9_-]+/g,'_');
}
function _uniqueAgentsById(list){
  const seen=new Set(), out=[];
  for(const item of list||[]){
    if(!item || !item.agentId || seen.has(item.agentId)) continue;
    seen.add(item.agentId); out.push(item);
  }
  return out;
}
function _inferRoleFromStageLabel(label){
  const s=String(label||'').toLowerCase();
  if(/qa|테스트|검증/.test(s)) return 'qa';
  if(/검토|감리|review/.test(s)) return 'inspector';
  if(/디자인|ux|ui/.test(s)) return 'designer';
  if(/시장|트렌드|조사|리서치|research/.test(s)) return 'researcher';
  if(/설계|개발|구현|배포|deploy|dev/.test(s)) return 'developer';
  if(/기획|방향|문서|plan/.test(s)) return 'planner';
  return null;
}
function _resolvedStageAgentId(stage, roster){
  const explicit = stage && (stage.agentId || stage.agent);
  if(explicit) return explicit;
  const role = _inferRoleFromStageLabel(stage && stage.label);
  if(!role) return null;
  const match = (roster||[]).find(r=>r && r.roleCategory===role);
  return match ? match.agentId : null;
}
function _pipelineRosterOrder(roster, pipeline){
  const list=Array.isArray(roster)?roster.slice():[];
  if(!pipeline || !Array.isArray(pipeline.stages) || !pipeline.stages.length) return list;
  const byId=new Map(list.map(r=>[r.agentId,r]));
  const ordered=[];
  const ceo=byId.get('ceo');
  if(ceo) ordered.push(ceo);
  for(const stage of pipeline.stages){
    const aid=_resolvedStageAgentId(stage,list);
    const agent=aid && byId.get(aid);
    if(agent) ordered.push(agent);
  }
  for(const agent of list) ordered.push(agent);
  return _uniqueAgentsById(ordered);
}
function _stationSpriteFor(slot, roleCategory){
  if(roleCategory==='ceo') return 'astra-desk-exec';
  return 'astra-desk-work';
}
function _stationSlots(count){
  const rows=[];
  if(count <= 0) return rows;
  const cols = count <= 2 ? count : (count >= 7 ? 4 : 3);
  const rowCount = Math.ceil(count / cols);
  const xSets = {
    1:[308],
    2:[216,400],
    3:[124,308,492],
    4:[78,230,382,534],
  };
  for(let row=0; row<rowCount; row++){
    const remaining = count - row*cols;
    const inRow = Math.min(cols, remaining);
    const xs = xSets[inRow] || xSets[3];
    const rowYs = rowCount === 1 ? [300] : (rowCount === 2 ? [304,432] : [270,380,490]);
    const y = rowYs[row] ?? (490 + Math.max(0,row-rowYs.length+1)*88);
    xs.forEach((x)=>rows.push([x,y,'R']));
  }
  return rows;
}
function _makeStationForRosterAgent(agent, slot, slots){
  if(agent.roleCategory==='ceo' || agent.agentId==='ceo'){
    return {
      key:'ceo',agentKey:agent.agentId,label:agent.agentName||'CEO',charRow:_roleCategoryToCharRow(agent.roleCategory),
      deskSprite:'astra-desk-exec',deskX:286,deskY:188,deskW:140,seatX:319,seatY:218,face:'R',
      dock:[350,268],roam:[[304,280],[402,280]],boss:true,
    };
  }
  const [deskX,deskY,face]=(slots && slots[slot]) || [308,318,'R'];
  const deskW=112;
  const seatX=deskX+4;
  const seatY=deskY+36;
  return {
    key:_safeDeskKey(agent.agentId),agentKey:agent.agentId,label:agent.agentName||agent.agentId,
    charRow:_roleCategoryToCharRow(agent.roleCategory),deskSprite:_stationSpriteFor(slot,agent.roleCategory),
    deskX,deskY,deskW,seatX,seatY,face,boss:false,
    dock:[deskX+32,deskY+82],
    roam:[[deskX+12,deskY+112],[deskX+74,deskY+104]],
  };
}
function _rebuildScene(nextStations, props){
  _clearStage();
  stations=(nextStations||[]).map(st=>Object.assign({},st));
  _rebuildStationIndex();
  stations.forEach(buildStation);
  (props||DEFAULT_PROPS).forEach(p=>addImg(p.name,p.x,p.y,p.w));
}
function _restoreDefaultScene(){
  _rebuildScene(DEFAULT_STATIONS,DEFAULT_PROPS);
  _layoutSource='default';
  _lastAutoLayoutSignature='';
  const shell=document.querySelector('.office-shell');
  if(shell) shell.dataset.layout='default';
}
function _looksLikeLegacyShowroomLayout(snap){
  if(!snap || !Array.isArray(snap.cells) || snap.cells.length!==8) return false;
  return snap.cells.every(c=>{
    const legacy=LEGACY_SHOWROOM_POS[c && c.roleKey];
    return !!legacy
      && Math.abs((c.deskX||0)-legacy[0])<=2
      && Math.abs((c.deskY||0)-legacy[1])<=2;
  });
}
function _syncOfficeLayout(pipeline, roster){
  if(_hasSavedLayout || _editMode) return;
  const stages=pipeline && Array.isArray(pipeline.stages) ? pipeline.stages : [];
  const ordered=stages.length ? _pipelineRosterOrder(roster,pipeline) : _uniqueAgentsById(roster||[]);
  if(!ordered.length){
    if(_layoutSource!=='default') _restoreDefaultScene();
    return;
  }
  const signature=ordered.map(r=>r.agentId).join('|')+'::'+stages.map(s=>(s.label||'')+':'+(_resolvedStageAgentId(s,ordered)||'')).join('|');
  if(_layoutSource==='roster' && signature===_lastAutoLayoutSignature) return;
  const ceo=ordered.find(r=>r.agentId==='ceo' || r.roleCategory==='ceo');
  const workers=ordered.filter(r=>r!==ceo);
  const slots=_stationSlots(workers.length);
  const next=[];
  if(ceo) next.push(_makeStationForRosterAgent(ceo,0,slots));
  workers.forEach((agent,idx)=>next.push(_makeStationForRosterAgent(agent,idx,slots)));
  if(next.length){
    _rebuildScene(next,DEFAULT_PROPS);
    _layoutSource='roster';
    _lastAutoLayoutSignature=signature;
    const shell=document.querySelector('.office-shell');
    if(shell) shell.dataset.layout=stages.length?'pipeline':'roster';
  }
}
function _clearPipelineDecor(){
  Object.values(__deskWrap).forEach(wrap=>{
    wrap.classList.remove('pipeline-desk');
    delete wrap.dataset.flow;
    wrap.querySelectorAll('.flow-badge').forEach(el=>el.remove());
  });
  const flow=document.getElementById('flowLayer');
  if(flow) flow.innerHTML='';
}
function _deskCenterForAgent(agentId){
  const st=findStationByAgent(agentId);
  if(!st) return null;
  const wrap=__deskWrap[st.key];
  if(!wrap) return null;
  const x=parseFloat(wrap.style.left)+(parseFloat(wrap.style.width)||st.deskW||112)/2;
  const y=parseFloat(wrap.style.top)+34;
  return {x,y,st,wrap};
}
function _renderPipelineDecor(pipeline, roster){
  _clearPipelineDecor();
  const stages=pipeline && Array.isArray(pipeline.stages) ? pipeline.stages : [];
  if(!stages.length) return;
  const stageAgents=stages.map(stg=>({ stage:stg, agentId:_resolvedStageAgentId(stg,roster) })).filter(x=>!!x.agentId);
  if(!stageAgents.length) return;
  const meta=new Map();
  stageAgents.forEach((item,idx)=>{
    const prev=meta.get(item.agentId)||{first:idx+1,count:0,statuses:[]};
    prev.count++;
    prev.statuses.push(item.stage.status||'pending');
    meta.set(item.agentId,prev);
  });
  meta.forEach((m,agentId)=>{
    const center=_deskCenterForAgent(agentId);
    if(!center) return;
    const status=m.statuses.includes('active') ? 'active' : (m.statuses.every(s=>s==='done') ? 'done' : 'pending');
    center.wrap.classList.add('pipeline-desk');
    center.wrap.dataset.flow=status;
    const badge=document.createElement('div');
    badge.className='flow-badge';
    badge.textContent=m.count>1 ? (m.first+'×'+m.count) : String(m.first);
    center.wrap.appendChild(badge);
  });
  const compact=[], seenRouteAgents=new Set();
  for(const item of stageAgents){
    if(seenRouteAgents.has(item.agentId)) continue;
    seenRouteAgents.add(item.agentId);
    compact.push(item);
  }
  const pts=compact.map(item=>{
    const center=_deskCenterForAgent(item.agentId);
    const m=meta.get(item.agentId);
    const status=m && m.statuses.includes('active') ? 'active' : (m && m.statuses.every(s=>s==='done') ? 'done' : 'pending');
    return { ...item, status, center };
  }).filter(x=>!!x.center);
  const flow=document.getElementById('flowLayer');
  if(!flow || pts.length<1) return;
  if(pts.length>1){
    const path=document.createElementNS('http://www.w3.org/2000/svg','path');
    path.setAttribute('class','flow-route');
    path.setAttribute('d',pts.map((p,i)=>(i?'L':'M')+p.center.x+' '+p.center.y).join(' '));
    flow.appendChild(path);
  }
  pts.forEach((p)=>{
    const c=document.createElementNS('http://www.w3.org/2000/svg','circle');
    c.setAttribute('class','flow-node');
    c.setAttribute('cx',String(p.center.x));
    c.setAttribute('cy',String(p.center.y));
    c.setAttribute('r',p.status==='active'?'7':'5');
    c.dataset.status=p.status||'pending';
    flow.appendChild(c);
  });
}
// agentKey \u2192 station.key \uB85C \uB77C\uC6B0\uD305. roleMap \uC758 \uB3D9\uC801 \uBC84\uC804.
function findStationByAgent(agentId){
  if(!agentId) return null;
  const a = AGENT_ALIASES[agentId] || agentId;
  for(const st of stations){ if(st.agentKey === a) return st; }
  return null;
}
// \uC61B \uCF54\uB4DC \uD638\uD658 \u2014 roleMap[x] \uD638\uCD9C \uD328\uD134\uC744 \uD568\uC218\uB85C.
const roleMap=new Proxy({},{get:(_,k)=>{ const s=findStationByAgent(k); return s?s.key:null; }});

function addImg(name,x,y,w){
  const i=document.createElement('img');
  i.src=png(name); i.className='obj';
  i.dataset.objId='obj_'+(__nextObjN++); i.dataset.objName=name;
  if(w!=null) i.dataset.objW=w;
  i.style.left=x+'px'; i.style.top=y+'px';
  if(w!=null) i.style.width=w+'px';
  stage.appendChild(i); return i;
}

function addDesk(st){
  const wrap=document.createElement('div');
  wrap.className='desk '+(st.boss?'boss':'');
  wrap.dataset.role=st.key;
  if(st.agentKey) wrap.dataset.agent=st.agentKey;
  wrap.style.left=st.deskX+'px'; wrap.style.top=st.deskY+'px'; wrap.style.width=st.deskW+'px';
  const img=document.createElement('img'); img.src=png(st.deskSprite); img.style.width='100%';
  wrap.appendChild(img);
  const l=document.createElement('div'); l.className='label'; l.textContent=st.label;
  wrap.appendChild(l);
  stage.appendChild(wrap);
  __deskWrap[st.key]=wrap;
  return wrap;
}

function addChar(st){
  const ch=document.createElement('div');
  ch.className='char'; ch.dataset.role=st.key;
  if(st.agentKey) ch.dataset.agent=st.agentKey;
  ch.style.left=st.seatX+'px'; ch.style.top=st.seatY+'px';
  ch.dataset.homeX=st.seatX; ch.dataset.homeY=st.seatY; ch.dataset.row=st.charRow;
  ch.innerHTML='<img><div class="shadow"></div>';
  stage.appendChild(ch);
  chars[st.key]=ch;
  anim[st.key]={row:st.charRow,frame:0,dir:0,mode:'sit',face:st.face,route:0};
  const img=ch.querySelector('img');
  if(st.face === 'U' || st.face === 'D'){
    img.src=png('walk-r'+st.charRow+'-d'+_faceToWalkDir(st.face)+'-f0');
    img.style.transform='none';
  } else {
    img.src=png('idle-r'+st.charRow+'-f0');
    img.style.transform=st.face==='R'?'scaleX(-1)':'none';
  }
  return ch;
}

function buildStation(st){ addDesk(st); if(!st.noChar) addChar(st); }

_restoreDefaultScene();
// ── 4방향 dir 매핑 ──
// PNG 파일명 규약: walk-r<row>-d<DIR>-f<frame>.png
// 사용자의 sprite 컨벤션이 다르면 이 4 상수만 수정하면 됨.
// 기본값은 일반적인 RPG 4방향 시트 순서 (caliverse · RPGMaker 등).
const DIR_DOWN  = 0;  // 정면 (카메라/사용자 쪽)
const DIR_LEFT  = 1;
const DIR_RIGHT = 2;
const DIR_UP    = 3;  // 뒤
// idle/work 의 정면(D) / 후면(U) sprite 는 별도로 없으므로 walk 의 같은 방향
// frame 0 (정지 포즈) 를 그대로 빌려쓴다.
function _faceToWalkDir(face){
  if(face === 'D') return DIR_DOWN;
  if(face === 'U') return DIR_UP;
  if(face === 'L') return DIR_LEFT;
  return DIR_RIGHT;
}
function setSprite(role,mode,frame=0,dir=0){
  const ch=chars[role]; if(!ch) return;
  const a=anim[role]; if(!a) return;
  a.mode=mode;a.frame=frame;a.dir=dir;
  ch.classList.toggle('walking',mode==='walk');
  ch.classList.toggle('working',mode==='work');
  const img=ch.querySelector('img');
  if(mode==='walk'){
    // 4방향 walk sprite — 좌우 sprite를 따로 제공하므로 scaleX 반전은 *안* 한다.
    img.src=png('walk-r'+a.row+'-d'+dir+'-f'+frame);
    img.style.transform='none';
  } else if(a.face === 'U' || a.face === 'D'){
    // 위/아래 face 는 idle/work sprite 가 없으므로 walk 의 같은 방향 정지 포즈로.
    img.src=png('walk-r'+a.row+'-d'+_faceToWalkDir(a.face)+'-f0');
    img.style.transform='none';
  } else if(mode==='work'){
    // 기존 work sprite 는 불꽃 연출이 과도해 제품 톤을 깨뜨렸다.
    // 작업 중임은 CSS focus treatment 로 표현하고 캐릭터 본체는 idle 계열을 유지.
    img.src=png('idle-r'+a.row+'-f'+(frame%2));
    img.style.transform=(a.face==='R'?'scaleX(-1)':'none');
  } else {
    img.src=png('idle-r'+a.row+'-f'+frame);
    img.style.transform=(a.face==='R'?'scaleX(-1)':'none');
  }
}
function move(role,x,y){
  const ch=chars[role]; if(!ch) return; // 캐릭터 삭제된 자리면 무시.
  const a=anim[role]; if(!a) return;
  const cx=parseFloat(ch.style.left),cy=parseFloat(ch.style.top),
        dx=x-cx,dy=y-cy;
  // 주축 결정: 큰 쪽을 우선해 4방향 중 하나로 매핑. 동률이면 가로 우선.
  let dir;
  if(Math.abs(dx) >= Math.abs(dy)){
    dir = dx >= 0 ? DIR_RIGHT : DIR_LEFT;
  } else {
    dir = dy >= 0 ? DIR_DOWN : DIR_UP;
  }
  // 도착 후 idle/sit 시 좌우 face도 마지막 가로 이동에 맞춰 갱신. UP/DOWN은
  // 기존 face 유지 — 4방향 idle sprite가 없을 때 좌우만이라도 자연스럽게.
  if(dir === DIR_LEFT)  a.face = 'L';
  if(dir === DIR_RIGHT) a.face = 'R';
  setSprite(role,'walk',0,dir);
  ch.style.left=x+'px';
  ch.style.top=y+'px';
}
// ── Managed intervals (pause while the office view is hidden) ──
// The pixel-office runs several animation/roam/banter intervals. While the
// webview tab is not visible they do invisible work and keep timers hot —
// wasteful. _managedInterval registers each one; a visibilitychange handler
// pauses them all when the document is hidden and resumes them when shown.
// Behavior while visible is unchanged (same callbacks, same periods).
const _managedIntervals=[];
function _managedInterval(fn,ms){
  const rec={fn:fn,ms:ms,id:null};
  rec.id=setInterval(fn,ms);
  _managedIntervals.push(rec);
  return rec;
}
function _pauseManagedIntervals(){
  for(const rec of _managedIntervals){ if(rec.id!==null){ clearInterval(rec.id); rec.id=null; } }
}
function _resumeManagedIntervals(){
  for(const rec of _managedIntervals){ if(rec.id===null){ rec.id=setInterval(rec.fn,rec.ms); } }
}
// 명시적 cleanup — webview iframe 이 destroy 되면 JS 컨텍스트 통째로 GC 되니
// 현실적 누수는 없지만, 미래에 같은 iframe 안에서 runtime 을 reinit 하는 코드
// 추가될 수 있으므로 *명시적* dispose 경로를 둔다. window 에 노출해서 panel
// 호스트 (sidebarProvider.openPixelOfficePanel) 측에서 명시 호출 가능.
function _disposeAllManagedIntervals(){
  for(const rec of _managedIntervals){ if(rec.id!==null) clearInterval(rec.id); }
  _managedIntervals.length=0;
}
(window).__astraOfficeDisposeIntervals=_disposeAllManagedIntervals;
document.addEventListener('visibilitychange',()=>{
  if(document.hidden) _pauseManagedIntervals();
  else _resumeManagedIntervals();
});
// pagehide / beforeunload — webview navigation 또는 panel dispose 시 fallback.
window.addEventListener('pagehide',_disposeAllManagedIntervals,{once:true});
_managedInterval(()=>{Object.keys(chars).forEach(k=>{const a=anim[k];if(a.mode==='walk'){a.frame=(a.frame+1)%5;setSprite(k,'walk',a.frame,a.dir)}else if(a.mode==='work'){a.frame=(a.frame+1)%4;setSprite(k,'work',a.frame)} });},286)
_managedInterval(()=>{Object.keys(chars).forEach(k=>{const a=anim[k];if(a.mode==='sit'){a.frame=(a.frame+1)%2;setSprite(k,'sit',a.frame)} });},700)
// ── 책상 회피 path planner ──
// walkPath의 각 leg를 직선이 아닌 *책상을 우회하는* L자 또는 corridor 경로로
// 펴서 캐릭터가 책상을 가로지르지 않게. 책상이 회전됐을 때를 대비해 padding
// 충분히. 사용자 layout이 너무 빡빡해 모든 시도가 fail이면 직선 fallback.
function _deskRects(){
  return stations.map(st=>{
    const w=__deskWrap[st.key]; if(!w) return null;
    const x=parseFloat(w.style.left), y=parseFloat(w.style.top);
    const ww=parseFloat(w.style.width)||100;
    const hh=w.offsetHeight||40;
    const pad=20;
    return {x:x-pad, y:y-pad, w:ww+pad*2, h:hh+pad*2};
  }).filter(Boolean);
}
function _segIntersect(x1,y1,x2,y2,x3,y3,x4,y4){
  const denom=(x1-x2)*(y3-y4)-(y1-y2)*(x3-x4);
  if(Math.abs(denom)<0.0001) return false;
  const t=((x1-x3)*(y3-y4)-(y1-y3)*(x3-x4))/denom;
  const u=-((x1-x2)*(y1-y3)-(y1-y2)*(x1-x3))/denom;
  return t>=0&&t<=1&&u>=0&&u<=1;
}
function _segHitsRect(x1,y1,x2,y2,r){
  // 양 끝점이 rect 안이면 즉시 충돌
  const inR=(x,y)=>x>=r.x&&x<=r.x+r.w&&y>=r.y&&y<=r.y+r.h;
  if(inR(x1,y1)||inR(x2,y2)) return true;
  // 4변과 교차 검사
  return _segIntersect(x1,y1,x2,y2,r.x,r.y,r.x+r.w,r.y)
      || _segIntersect(x1,y1,x2,y2,r.x+r.w,r.y,r.x+r.w,r.y+r.h)
      || _segIntersect(x1,y1,x2,y2,r.x+r.w,r.y+r.h,r.x,r.y+r.h)
      || _segIntersect(x1,y1,x2,y2,r.x,r.y+r.h,r.x,r.y);
}
function _pathClear(pts,rects){
  for(let i=0;i<pts.length-1;i++){
    const [a,b]=[pts[i],pts[i+1]];
    for(const r of rects){ if(_segHitsRect(a[0],a[1],b[0],b[1],r)) return false; }
  }
  return true;
}
function _planPath(fromX,fromY,toX,toY){
  const rects=_deskRects();
  if(rects.length===0) return [[toX,toY]];
  // 1) 직선
  if(_pathClear([[fromX,fromY],[toX,toY]],rects)) return [[toX,toY]];
  // 2) 가로 먼저 L
  if(_pathClear([[fromX,fromY],[toX,fromY],[toX,toY]],rects)) return [[toX,fromY],[toX,toY]];
  // 3) 세로 먼저 L
  if(_pathClear([[fromX,fromY],[fromX,toY],[toX,toY]],rects)) return [[fromX,toY],[toX,toY]];
  // 4) 사무실 corridor를 통과 — y 통로 후보 (윗·중간·아랫줄 책상 사이)
  // stage 높이에 따라 자동 후보 생성: 0, h/4, h/2, 3h/4, h
  const sh=stage.offsetHeight||600;
  const ycands=[20,Math.round(sh*0.32),Math.round(sh*0.6),Math.round(sh*0.82),sh-20];
  for(const cy of ycands){
    const trial=[[fromX,fromY],[fromX,cy],[toX,cy],[toX,toY]];
    if(_pathClear(trial,rects)) return [[fromX,cy],[toX,cy],[toX,toY]];
  }
  // 5) x 통로 후보
  const sw=stage.offsetWidth||700;
  const xcands=[20,Math.round(sw*0.32),Math.round(sw*0.6),Math.round(sw*0.82),sw-20];
  for(const cx of xcands){
    const trial=[[fromX,fromY],[cx,fromY],[cx,toY],[toX,toY]];
    if(_pathClear(trial,rects)) return [[cx,fromY],[cx,toY],[toX,toY]];
  }
  // 최후의 fallback — 직선 (책상을 가로지를 수 있지만 적어도 멈추진 않음)
  return [[toX,toY]];
}
function walkPath(role,points,done,route){
  const a=anim[role],token=route??++a.route;
  if(token!==a.route) return;
  if(!points.length){ if(done) done(); return; }
  const ch=chars[role];
  const cx=parseFloat(ch.style.left), cy=parseFloat(ch.style.top);
  const [pt,...rest]=points;
  // 현재 위치 → 다음 waypoint를 책상 회피 경로로 펴기.
  const planned=_planPath(cx,cy,pt[0],pt[1]);
  // 첫 step 이동하고, 나머지 planned 점들 + 원본 rest를 큐로.
  const next=planned[0];
  move(role,next[0],next[1]);
  const tail=planned.slice(1).concat(rest);
  setTimeout(()=>walkPath(role,tail,done,token),1235);
}
function sendHome(role,mode='sit'){
  const st=stationByKey[role],ch=chars[role];
  if(!st || !ch) return; // 책상/캐릭터 부재 시 no-op.
  const hx=st.seatX,hy=st.seatY,cx=parseFloat(ch.style.left),cy=parseFloat(ch.style.top);
  anim[role].route++;
  if(Math.abs(cx-hx)<1&&Math.abs(cy-hy)<1){setSprite(role,mode);return;}
  walkPath(role,[st.dock,[hx,hy]],()=>setSprite(role,mode));
}
_managedInterval(()=>{
  if(!['idle','done'].includes(_prevStatus || 'idle')) return;
  const free=Object.keys(chars).filter(k=>anim[k]?.mode==='sit'&&!chars[k].classList.contains('active'));
  if(!free.length)return;
  const k=free[Math.floor(Math.random()*free.length)],st=stationByKey[k];
  if(!st || !Array.isArray(st.roam) || !st.roam.length || !Array.isArray(st.dock)) return;
  const pt=st.roam[Math.floor(Math.random()*st.roam.length)];
  walkPath(k,[st.dock,pt,st.dock,[st.seatX,st.seatY]],()=>setSprite(k,'sit'));
},9000);
function activate(role){Object.keys(chars).forEach(k=>chars[k].classList.toggle('active',k===role))}

// ── 검수 시나리오: 작성자를 검수자 옆으로 데려가기 + 안전거리 ──
// 작성자(writer) 가 작업 끝낸 후 status='reviewing' 진입하면 검수자 책상 옆으로 이동.
// 다른 캐릭터들과 겹치지 않도록 안전거리(>= SAFE_DIST px) 유지하면서 자리 후보 탐색.
const SAFE_DIST = 36;
function _isClearSpot(x, y, ignoreRole){
  for(const k of Object.keys(chars)){
    if(k === ignoreRole) continue;
    const c = chars[k]; if(!c) continue;
    const cx = parseFloat(c.style.left), cy = parseFloat(c.style.top);
    const dx = cx - x, dy = cy - y;
    if(Math.sqrt(dx*dx + dy*dy) < SAFE_DIST) return false;
  }
  return true;
}
function _findSpotNear(targetX, targetY, ignoreRole){
  // 검수자 책상 주변 8방향 원형 탐색 — 60px 부터 시작해 비면 즉시 채택.
  const offsets = [
    [60, 0], [-60, 0], [0, -60], [0, 60],
    [50, -40], [-50, -40], [50, 40], [-50, 40],
    [80, 0], [-80, 0], [0, -70], [0, 70],
  ];
  for(const [dx, dy] of offsets){
    const x = targetX + dx, y = targetY + dy;
    // stage 경계.
    if(x < 20 || y < 20 || x > (stage.offsetWidth - 60) || y > (stage.offsetHeight - 80)) continue;
    if(_isClearSpot(x, y, ignoreRole)) return [x, y];
  }
  // 모든 후보 실패 — fallback 으로 그냥 target 옆 60px (겹쳐도 어쩔 수 없음).
  return [targetX + 60, targetY];
}
/**
 * writerRole 캐릭터를 reviewerRole 의 자리 근처로 walk. 도착 후 standing 자세로 face 만 회전.
 * reviewing 모드 종료 시 _restoreWriterHome 으로 원위치.
 */
const _movedToReviewer = new Map(); // role → original home {x, y, face}
function _walkToReviewer(writerRole, reviewerRole){
  if(!writerRole || !reviewerRole) return;
  if(writerRole === reviewerRole) return;
  const writerCh = chars[writerRole], reviewerCh = chars[reviewerRole];
  if(!writerCh || !reviewerCh) return;
  if(_movedToReviewer.has(writerRole)) return; // 이미 가 있음
  const rx = parseFloat(reviewerCh.style.left), ry = parseFloat(reviewerCh.style.top);
  const [tx, ty] = _findSpotNear(rx, ry, writerRole);
  const a = anim[writerRole]; if(!a) return;
  // home 기억해뒀다가 reviewing 끝나면 복귀.
  _movedToReviewer.set(writerRole, {
    x: parseFloat(writerCh.style.left), y: parseFloat(writerCh.style.top), face: a.face,
  });
  walkPath(writerRole, [[tx, ty]], () => {
    // 도착 후 검수자 쪽 face. 좌우 위치만 비교 (목적지가 reviewer 왼쪽인지 오른쪽인지).
    if(a){ a.face = tx < rx ? 'R' : 'L'; }
    setSprite(writerRole, 'sit');
  });
}
function _restoreWriterHome(writerRole){
  const saved = _movedToReviewer.get(writerRole);
  if(!saved){
    // 옮긴 적 없으면 그냥 sendHome.
    sendHome(writerRole, 'sit');
    return;
  }
  _movedToReviewer.delete(writerRole);
  const a = anim[writerRole]; if(a) a.face = saved.face;
  // station seat 좌표로 복귀 (saved.x/y 는 옛 home 이지만 그 동안 사용자 layout 편집이 있을 수 있어 station 우선).
  sendHome(writerRole, 'sit');
}
function bubble(role,text){const ch=chars[role];if(!ch||!text)return;const b=document.createElement('div');b.className='bubble';b.textContent=text;b.style.left=(parseFloat(ch.style.left)+28)+'px';b.style.top=(parseFloat(ch.style.top)-6)+'px';stage.appendChild(b);setTimeout(()=>b.remove(),2400)}

// ── 로그 → 말풍선 요약기 ──
// 감리(inspector) 는 검토 결과를 키워드만 보여주고, 일반 에이전트는 짧게 잘라서 표시.
// 같은 에이전트에서 너무 자주 뜨면 산만하니 throttle.
const _lastBubbleAt = {};
const BUBBLE_MIN_GAP_MS = 2200;
const _KW_STOP = new Set([
  '은','는','이','가','을','를','의','에','도','과','와','으로','에서','부터','까지','마다','보다','처럼','같이','한테','한테서','한테',
  '입니다','합니다','했습니다','됩니다','있습니다','없습니다','하였습니다','하다','하는','한','했','됨','됨.',
  '그리고','하지만','또는','관련','관련된','대한','대해','그래서','이번','이걸','저걸','이거','저거',
  '확인','진행','수행','완료','시작','종료','발생','처리','요청','응답',
  '그리고','또한','그러나','즉','또는','및',
  '입력','출력','메시지','파일','코드','라인','함수','클래스','변수','값','상태',
]);
function _extractKeywords(text, count){
  const tokens = String(text).split(/[\\s,\\.\\?!，。、:;\\(\\)\\[\\]"'·…\\-]+/).filter(Boolean);
  const seen = new Set(); const out = [];
  // 길이 내림차순 — 의미있는 명사가 짧은 조사/어미보다 먼저 뽑히도록.
  const ranked = tokens
    .filter(t => t.length >= 2 && !_KW_STOP.has(t) && !/^[0-9]+$/.test(t))
    .sort((a,b)=> b.length - a.length);
  for(const t of ranked){
    if(seen.has(t)) continue;
    seen.add(t); out.push(t);
    if(out.length >= count) break;
  }
  return out;
}
function _bubbleSummary(role, raw){
  const s = String(raw||'').trim().replace(/[*\\[\\]\`]/g,'');
  if(!s) return '';
  const st = stationByKey[role];
  const isInspector = st && st.agentKey === 'inspector';
  if(isInspector){
    const kw = _extractKeywords(s, 3);
    return kw.length ? kw.join(' · ') : (s.length > 12 ? s.slice(0,11)+'…' : s);
  }
  // 일반: 한 줄 + 16자 잘라.
  const oneLine = s.split(/\\r?\\n/)[0];
  return oneLine.length > 18 ? oneLine.slice(0,17)+'…' : oneLine;
}
function _bubbleFromLog(role, raw){
  if(!role || !chars[role]) return;
  const now = Date.now();
  if((now - (_lastBubbleAt[role]||0)) < BUBBLE_MIN_GAP_MS) return;
  const txt = _bubbleSummary(role, raw);
  if(!txt) return;
  _lastBubbleAt[role] = now;
  bubble(role, txt);
}

// ── 속마음 라이브러리 (refactor: inner-thought bubbles) ──
// 각 직군이 작업 중 (mode='work') 무슨 생각을 하는지 — 자동 주기 emitter 가 6~9초마다
// 활성 character 중 한 명을 골라 한 줄 띄움. 사용자가 "지금 이 캐릭터가 뭘 고민 중인지"
// 느낌으로 알게. 로그 기반 _bubbleFromLog 와는 독립 — 둘 다 같은 throttle gate 통과.
const INNER_THOUGHTS = {
  ceo: {
    work: ['전체 그림 다시 한번', '리스크는?', '우선순위 재배치', '누구한테 맡길까', '결정 미루지 말자'],
    sit:  ['오늘 일정 확인…', '보고서 정리 시작', '커피 한 모금'],
  },
  planner: {
    work: ['핵심 시나리오 빠진 거 없나', '측정 가능한 기준 추가', '범위 너무 넓어졌어', '한 문단으로 줄여보자', '독자 입장에서…'],
    sit:  ['오늘 뭐부터 시작할까', '레퍼런스 한 번 더', '메모 다시 보자'],
  },
  researcher: {
    work: ['출처 한 번 더 확인', '비슷한 사례 있나', '데이터 vs 추측', '반증 케이스 찾자', '이거 일반론 아냐?'],
    sit:  ['검색 키워드 다듬기', '논문 한 편 더', '북마크 정리'],
  },
  designer: {
    work: ['시각 위계 맞나', '여백 더 줄까', '컬러 톤 일관성', '모바일에선 어떻게', '사용자 흐름 막힘 없나'],
    sit:  ['inspiration 좀…', '컴포넌트 라이브러리 봐야', '피그마 정리'],
  },
  developer: {
    work: ['엣지 케이스 빠짐', '이거 깨질 거 같은데', '함수 너무 길어', '테스트부터 쓸까', '에러 처리 누락'],
    sit:  ['이슈 목록 훑어', '리팩토링 백로그', '커밋 메시지 다듬기'],
  },
  qa: {
    work: ['이 케이스는 어떻게', '입력 비어있으면', '동시성 문제 가능', 'race condition', '경계값 한 번 더'],
    sit:  ['테스트 시나리오 정리', '회귀 케이스 추가', '버그 재현 절차'],
  },
  inspector: {
    work: ['기획 의도 일치 여부', '과한 over-engineering', '시나리오 누락', '측정 기준 명확한가', '재작업 여부 판단'],
    sit:  ['이전 검토 메모…', '체크리스트 정리', '기준 다듬기'],
  },
  support: {
    work: ['일정 충돌 확인', '담당 명확한가', '리마인더 시점', '회의록 정리', '이해관계자 누락'],
    sit:  ['오늘 처리할 것…', '캘린더 한 번 더', '미답 답장 확인'],
  },
};
function _innerThoughtFor(agentKey, mode){
  const bucket = INNER_THOUGHTS[agentKey];
  if(!bucket) return null;
  const pool = bucket[mode] || bucket.work || [];
  if(!pool.length) return null;
  return pool[Math.floor(Math.random()*pool.length)];
}
// 검수 중 작성자가 inspector 옆에 있을 때 띄울 "긴장한 작성자" 속마음 pool.
const REVIEW_NERVOUS_THOUGHTS = [
  '괜찮을까…', '재작업 안 나왔으면', '핵심은 다 들어갔지', '여기 빠뜨린 거 있나', '한 라운드면 통과해줘',
];
// 검수자가 keyword 형식으로 던지는 결론 어휘 — 로그 없을 때도 자체 emit.
const REVIEWER_KEYWORD_BANK = [
  ['시나리오 부족', '핵심 누락', '재작업 필요'],
  ['승인', '기준 충족', '진행 OK'],
  ['일반론', '구체화 필요', '예시 부족'],
  ['중복', '간결화', 'over-engineering'],
  ['엣지케이스', '경계값', '실패 케이스'],
  ['측정 기준', '성공 정의', 'KPI'],
];

// 6~9초 random 간격으로 활성 character 중 하나에서 속마음 emit. mode 가 work / sit 인 것만.
// 한 tick 에 *한 명만* 골라서 띄움 — 동시에 여러 풍선 뜨면 시각 부담.
function _innerThoughtTick(){
  // Banter 가 재생 중이면 일반 inner-thought 는 잠시 멈춤 — 동시 다발 풍선으로 산만해지지 않게.
  if(_activeBanter) return;
  // Reviewing 중: 검수자 본인은 keyword 던지고, 옮겨와 있는 작성자는 nervous thought.
  if(_prevStatus === 'reviewing'){
    const inspectorRole = roleMap['inspector'];
    if(inspectorRole && chars[inspectorRole]){
      // 50% 확률로 검수자가 keyword 던지기.
      if(Math.random() < 0.5){
        const set = REVIEWER_KEYWORD_BANK[Math.floor(Math.random()*REVIEWER_KEYWORD_BANK.length)];
        const txt = set.join(' · ');
        _bubbleFromLog(inspectorRole, txt);
        return;
      }
    }
    // 작성자 측 긴장 표현.
    for(const r of _movedToReviewer.keys()){
      if(Math.random() < 0.7){
        const t = REVIEW_NERVOUS_THOUGHTS[Math.floor(Math.random()*REVIEW_NERVOUS_THOUGHTS.length)];
        _bubbleFromLog(r, t);
        return;
      }
    }
  }
  // 일반: 작업 중 또는 sit 인 char 중 1명에서 속마음.
  const candidates = Object.keys(chars).filter(k => {
    const a = anim[k]; if(!a) return false;
    return a.mode === 'work' || a.mode === 'sit';
  });
  if(candidates.length === 0) return;
  const working = candidates.filter(k => anim[k].mode === 'work');
  const pool = working.length > 0 ? working : candidates;
  const role = pool[Math.floor(Math.random()*pool.length)];
  const st = stationByKey[role]; if(!st) return;
  const text = _innerThoughtFor(st.agentKey, anim[role].mode);
  if(text) _bubbleFromLog(role, text);
}
_managedInterval(_innerThoughtTick, 7500);

// ── Webtoon-style 티키타카 banter (refactor: pipeline-aware) ──
// 각 phase 에 *시퀀스화된 대화 script* 가 있어 phase 진입 시 한 줄씩 시간 차로 emit.
// 각 line 은 (agentKey, text, emotion) — emotion 은 bubble class 로 변환 (희노애락).
//
// 한 phase 마다 여러 variant 중 1개 random pick — 같은 흐름을 매번 안 보게.
// 진행 중 phase 가 바뀌면 즉시 중단.
const BANTER_SCRIPTS = {
  // 의도 분석 — CEO 가 planner 와 함께 요청 정리
  analyzing: [
    [
      { agent: 'ceo',     text: '의도 다시 보자.',           emotion: 'thought' },
      { agent: 'planner', text: '핵심은 명확한데…',           emotion: 'thought' },
      { agent: 'ceo',     text: '뭐가 막혀?',                 emotion: 'curious' },
      { agent: 'planner', text: '성공 기준이 모호해요.',      emotion: 'sorrow' },
      { agent: 'ceo',     text: '좋아, 그것부터 정하자.',     emotion: 'firm' },
    ],
    [
      { agent: 'planner', text: '범위가 너무 넓은데…',        emotion: 'sorrow' },
      { agent: 'ceo',     text: '첫 사용자 한 명만 그려보자.', emotion: 'firm' },
      { agent: 'planner', text: '오, 그러면 깔끔해지네요.',   emotion: 'joy' },
    ],
  ],
  intake: [
    [
      { agent: 'ceo',     text: '오, 새 요청.',               emotion: 'joy' },
      { agent: 'support', text: '브리프 정리해드릴게요.',     emotion: 'thought' },
      { agent: 'ceo',     text: '바로 보자.',                 emotion: 'firm' },
    ],
  ],
  // 사용자에게 확인 필요
  need_clarification: [
    [
      { agent: 'planner', text: '이건 추측으로 가면 위험.',   emotion: 'panic' },
      { agent: 'ceo',     text: '맞아. 사장님께 묻자.',       emotion: 'firm' },
      { agent: 'support', text: '질문 정리해서 띄울게요.',    emotion: 'thought' },
    ],
  ],
  // 계획
  planning: [
    [
      { agent: 'ceo',     text: '순서 잡자. 누가 먼저?',      emotion: 'firm' },
      { agent: 'planner', text: '리서치 → 설계 → 구현 흐름.', emotion: 'thought' },
      { agent: 'developer', text: '설계 전에 데이터 모델 확정', emotion: 'firm' },
      { agent: 'planner', text: '맞아요, 그게 우선.',         emotion: 'joy' },
    ],
    [
      { agent: 'planner', text: '여기 분기점이 두 개.',       emotion: 'thought' },
      { agent: 'ceo',     text: 'A 안 비용은?',               emotion: 'curious' },
      { agent: 'planner', text: 'B 보다 절반.',               emotion: 'thought' },
      { agent: 'ceo',     text: 'A 로 가자. 확정.',           emotion: 'firm' },
    ],
  ],
  // 구현
  executing: [
    [
      { agent: 'developer', text: '집중 모드 들어갑니다.',     emotion: 'firm' },
      { agent: 'qa',        text: '엣지케이스 같이 봐드려요?', emotion: 'curious' },
      { agent: 'developer', text: '아, 빈 입력 케이스 빠뜨릴 뻔', emotion: 'panic' },
      { agent: 'qa',        text: '네, 그거 자주 빼먹어요.',   emotion: 'thought' },
      { agent: 'developer', text: '고마워요.',                 emotion: 'gratitude' },
    ],
    [
      { agent: 'developer', text: '여기 너무 복잡한데.',       emotion: 'sorrow' },
      { agent: 'planner',   text: '단계 둘로 나누면?',          emotion: 'curious' },
      { agent: 'developer', text: '오, 그러면 되겠네요.',       emotion: 'joy' },
    ],
    [
      { agent: 'designer',  text: '여백 좀 더 줄까요?',         emotion: 'curious' },
      { agent: 'developer', text: '여기 컴포넌트 정렬 잘 못맞춰요…', emotion: 'sorrow' },
      { agent: 'designer',  text: 'flex gap 8 로 가면 깔끔.',   emotion: 'firm' },
      { agent: 'developer', text: '오케이 반영.',               emotion: 'gratitude' },
    ],
  ],
  // 검수
  reviewing: [
    [
      { agent: 'inspector', text: '시나리오 빠진 것 있나…',     emotion: 'thought' },
      { agent: 'planner',   text: '괜찮을까…',                  emotion: 'panic' },
      { agent: 'inspector', text: '여기 측정 기준 누락.',        emotion: 'firm' },
      { agent: 'planner',   text: '아 KPI 부분, 보강할게요.',    emotion: 'sorrow' },
      { agent: 'inspector', text: '그거만 채우면 통과.',         emotion: 'thought' },
    ],
    [
      { agent: 'inspector', text: '전체 흐름은 OK.',             emotion: 'thought' },
      { agent: 'inspector', text: '근데 여기 over-engineering.', emotion: 'firm' },
      { agent: 'developer', text: '단순화 어떻게…',              emotion: 'curious' },
      { agent: 'inspector', text: '함수 셋을 하나로.',           emotion: 'firm' },
      { agent: 'developer', text: '바로 줄일게요.',              emotion: 'gratitude' },
    ],
    [
      { agent: 'qa',        text: '회귀 케이스 1개 실패.',       emotion: 'panic' },
      { agent: 'developer', text: '어… 어떤 거?',                emotion: 'curious' },
      { agent: 'qa',        text: '동시 호출 race.',             emotion: 'firm' },
      { agent: 'developer', text: '아 그거… 죄송, 빨리 고칠게요.', emotion: 'sorrow' },
    ],
  ],
  // 승인 대기
  waiting_approval: [
    [
      { agent: 'inspector', text: '위험 작업 감지.',             emotion: 'panic' },
      { agent: 'ceo',       text: '사장님 결재 필요.',           emotion: 'firm' },
      { agent: 'support',   text: '승인 카드 띄울게요.',         emotion: 'thought' },
    ],
  ],
  error: [
    [
      { agent: 'developer', text: '앗, 이건 예상 못 했어요.',    emotion: 'panic' },
      { agent: 'qa',        text: '재현 단계는?',                emotion: 'curious' },
      { agent: 'developer', text: '지금 정리 중…',                emotion: 'sorrow' },
      { agent: 'inspector', text: '뭐가 깨졌나 같이 보자.',       emotion: 'firm' },
    ],
  ],
  done: [
    [
      { agent: 'ceo',       text: '좋아, 끝났다!',                emotion: 'joy' },
      { agent: 'planner',   text: '이번 작업 깔끔하게 완료.',     emotion: 'joy' },
      { agent: 'developer', text: '커피 한 잔!',                  emotion: 'joy' },
      { agent: 'support',   text: '회의록·일정 자동 정리 했어요.', emotion: 'joy' },
    ],
  ],
};

// 감정 태그 → CSS class. _emoteBubble 가 .bubble + .bubble-<emotion> 으로 렌더.
function _emoteBubble(role, text, emotion){
  const ch = chars[role];
  if(!ch || !text) return;
  const b = document.createElement('div');
  b.className = 'bubble bubble-' + (emotion || 'thought');
  b.textContent = text;
  b.style.left = (parseFloat(ch.style.left) + 28) + 'px';
  b.style.top  = (parseFloat(ch.style.top) - 6) + 'px';
  stage.appendChild(b);
  setTimeout(() => b.remove(), 3200);
}

// 활성 banter 추적 — phase 가 바뀌면 즉시 중단해서 새 script 시작.
let _activeBanter = null;
function _stopBanter(){
  if(_activeBanter && _activeBanter.timer){ clearTimeout(_activeBanter.timer); }
  _activeBanter = null;
}
function _stepBanter(){
  if(!_activeBanter) return;
  if(_activeBanter.idx >= _activeBanter.lines.length){ _activeBanter = null; return; }
  const line = _activeBanter.lines[_activeBanter.idx++];
  const role = roleMap[line.agent];
  if(role){
    _emoteBubble(role, line.text, line.emotion);
  }
  // 다음 줄까지 1.8~2.4초 — 자연스러운 대화 호흡.
  const delay = 1800 + Math.floor(Math.random() * 600);
  _activeBanter.timer = setTimeout(_stepBanter, delay);
}
function _playBanterForPhase(phaseOrStatus){
  if(!phaseOrStatus) return;
  const variants = BANTER_SCRIPTS[phaseOrStatus];
  if(!Array.isArray(variants) || variants.length === 0) return;
  const script = variants[Math.floor(Math.random() * variants.length)];
  if(!Array.isArray(script) || script.length === 0) return;
  _stopBanter();
  _activeBanter = { lines: script, idx: 0, timer: null };
  // 첫 줄 즉시.
  _stepBanter();
}
// ── A. 상태 계층화 ──
// 모든 phase event에 시퀀스를 큐에 넣으면 캐릭터가 끊임없이 걸어다녀 산만함.
// 일반 상태(executing/reviewing/planning/analyzing 등)는 *정적 갱신*만 하고,
// critical pivot(error/need_clarification/review_failed/approval/done 등)에서만
// 캐릭터 시뮬레이션 + 말풍선 강조. 사용자에게 정말 *알릴 만한 변화*에 시각
// 집중력 몰아주기.
const CRITICAL_STATUSES = new Set(['need_clarification','waiting_approval','error','done']);
const CRITICAL_BUBBLE_TYPES = new Set(['warning','error','success']);
const CRITICAL_TEXT_RE = /버그|오류|실패|승인|중단|완료|❌|🛑|✅|⚠️|보완|재작업|결재/;
function _isCriticalBubble(b){
  if(!b) return false;
  if(b.type && CRITICAL_BUBBLE_TYPES.has(b.type)) return true;
  if(b.text && CRITICAL_TEXT_RE.test(b.text)) return true;
  return false;
}
function routeBubble(b){
  // 일반 status bubble은 무시 — 큐 적체 방지. 사용자에게 의미 있는 신호만 통과.
  if(!_isCriticalBubble(b)) return;
  const role = roleMap[b?.agentId] || 'ceo';
  bubble(role, b?.text || '');
}
// ⚠️ 새 phase 추가 시 다음 *세* 곳을 모두 갱신:
//   1. STATUS_COPY (이 객체) — label / note / tone
//   2. BANTER_SCRIPTS (위) — banter 시퀀스 (없으면 banter 없는 정적 phase)
//   3. PHASE_META.allPhases() 호출처 — 신규 phase 등장 시 fallback 검증
// 데이터 통합 (한 객체로 합치기) 은 BANTER 객체가 매우 커서 다음 라운드로. 지금은
// 통합된 *접근 API* (getPhaseMeta) 만 제공해 호출자가 두 객체를 직접 안 봐도 되게.
const STATUS_COPY = {
  idle:               { label:'대기 중', note:'새로운 작업 요청을 기다리고 있습니다.', tone:'neutral' },
  intake:             { label:'요청 수신', note:'요청을 읽고 작업 범위를 정리하고 있습니다.', tone:'neutral' },
  analyzing:          { label:'의도 분석', note:'목표와 제약을 정리하는 중입니다.', tone:'neutral' },
  need_clarification: { label:'확인 필요', note:'결정을 위해 추가 입력이 필요합니다.', tone:'warning' },
  contract_ready:     { label:'브리프 확정', note:'요구사항을 실행 가능한 형태로 정리했습니다.', tone:'neutral' },
  planning:           { label:'설계 중', note:'실행 순서와 담당을 배치하고 있습니다.', tone:'neutral' },
  executing:          { label:'실행 중', note:'담당 에이전트가 실제 작업을 진행 중입니다.', tone:'neutral' },
  reviewing:          { label:'검수 중', note:'산출물의 완성도와 리스크를 점검하고 있습니다.', tone:'neutral' },
  waiting_approval:   { label:'승인 대기', note:'다음 진행을 위해 사용자의 판단을 기다립니다.', tone:'warning' },
  error:              { label:'주의 필요', note:'흐름을 멈춘 이슈를 확인해야 합니다.', tone:'danger' },
  done:               { label:'완료', note:'이번 작업 라운드가 정리되었습니다.', tone:'success' },
};

/**
 * 통합된 phase 메타 접근 API — STATUS_COPY 와 BANTER_SCRIPTS 두 객체를 *한 entry
 * point* 로 노출. 호출자는 이 함수만 알면 됨.
 *
 *   const meta = getPhaseMeta('executing');
 *   meta.label   // '실행 중'
 *   meta.tone    // 'neutral'
 *   meta.banter  // 시퀀스 array 또는 null (banter 없는 정적 phase)
 *
 * 모르는 phase 면 idle fallback.
 */
function getPhaseMeta(phase){
  const copy = STATUS_COPY[phase] || STATUS_COPY.idle;
  return {
    label: copy.label,
    note: copy.note,
    tone: copy.tone,
    banter: BANTER_SCRIPTS[phase] || null,
  };
}
function _statusMeta(status){
  // 옛 _statusMeta 호출처 호환 — banter 까지 같이 반환해도 무해. 호출자가 label/note/tone 만 쓰면 banter 는 무시됨.
  return getPhaseMeta(status);
}
function _pct(v){
  return Math.max(0, Math.min(100, Math.round((typeof v === 'number' ? v : 0) * 100)));
}
let _prevStatus = null;
// 마지막으로 inspector 가 아니었던 active role — reviewing 진입 시 *작성자* 후보.
let _lastWriterRole = null;
let _lastRenderedLog = null;
function apply(s){
  _lastState = s; // D. 컨텍스트 메뉴 / 세부보기에서 사용.
  const st = s?.status || 'idle';
  const meta = _statusMeta(st);
  const officeShell = document.querySelector('.office-shell');
  if(officeShell) officeShell.dataset.status = st;
  // 정적 갱신은 *항상* — 헤더/태스크/단계/로그/프로그레스.
  const statusEl = document.getElementById('status');
  const phasePill = document.getElementById('phasePill');
  if(statusEl) statusEl.textContent = meta.label;
  if(phasePill) phasePill.dataset.tone = meta.tone;
  document.getElementById('agent').textContent = s?.agentName || 'Astra';
  document.getElementById('task').textContent = s?.currentTask || '새 요청을 기다리고 있습니다.';
  document.getElementById('step').textContent = s?.currentStep || meta.label;
  document.getElementById('phaseLabel').textContent = meta.label;
  document.getElementById('phaseNote').textContent = meta.note;
  const lastLog = (s?.recentLogs||[]).slice(-1)[0];
  document.getElementById('log').textContent = lastLog || '아직 기록된 활동이 없습니다.';
  // 작업 중 (executing/reviewing) 상태에 활성 에이전트 머리 위 말풍선 — 로그 변할 때마다.
  if(lastLog && lastLog !== _lastRenderedLog){
    _lastRenderedLog = lastLog;
    if(['executing','reviewing','planning','analyzing'].includes(s?.status||'')){
      // 활성 role 추정: message prefix 또는 status default. 책상이 없으면 무시.
      let r = null;
      const mm2 = (s?.message||'').match(/^([a-z0-9_-]+)/i);
      if(mm2) r = roleMap[mm2[1]];
      if(!r && s?.status === 'reviewing') r = roleMap['inspector'];
      if(!r) r = roleMap['ceo'];
      _bubbleFromLog(r, lastLog);
    }
  }
  const progressPct = _pct(s?.progress);
  document.getElementById('bar').style.width = progressPct + '%';
  document.getElementById('progressLabel').textContent = progressPct + '%';
  const attentionCard = document.getElementById('attentionCard');
  const attentionTitle = document.getElementById('attentionTitle');
  const attentionBody = document.getElementById('attentionBody');
  if(s?.awaitingApproval){
    attentionCard.dataset.tone = 'warning';
    attentionTitle.textContent = '승인 필요';
    attentionBody.textContent = s.awaitingApproval;
  } else if(Array.isArray(s?.needUserInput) && s.needUserInput.length){
    attentionCard.dataset.tone = 'warning';
    attentionTitle.textContent = '추가 입력 필요';
    attentionBody.textContent = s.needUserInput[0];
  } else if(st === 'error'){
    attentionCard.dataset.tone = 'danger';
    attentionTitle.textContent = '이슈 감지';
    attentionBody.textContent = lastLog || meta.note;
  } else {
    attentionCard.dataset.tone = '';
    attentionTitle.textContent = '없음';
    attentionBody.textContent = '현재 막힘 없이 진행 중입니다.';
  }
  // B. 미니 맵 렌더 — pipelineStages가 있을 때만 보임.
  const mm = document.getElementById('miniMap');
  const stages = s?.pipelineStages;
  if(Array.isArray(stages) && stages.length > 0){
    mm.style.display = 'flex';
    mm.innerHTML = '';
    const doneN = stages.filter(x=>x.status==='done').length;
    stages.forEach((stg, i)=>{
      const dot = document.createElement('div');
      dot.className = 'mm-dot';
      dot.dataset.status = stg.status || 'pending';
      const label = document.createElement('span');
      label.className = 'mm-label';
      label.textContent = (i+1) + '. ' + (stg.label || '단계') + ((stg.agentId || stg.agent) ? ' · ' + (stg.agentId || stg.agent) : '');
      dot.appendChild(label);
      mm.appendChild(dot);
      if(i < stages.length - 1){
        const bar = document.createElement('div');
        bar.className = 'mm-bar';
        mm.appendChild(bar);
      }
    });
    const counter = document.createElement('div');
    counter.className = 'mm-counter';
    counter.textContent = doneN + '/' + stages.length;
    mm.appendChild(counter);
  } else {
    mm.style.display = 'none';
  }
  setTimeout(_fitStage, 0);
  // 활성 캐릭터 결정. roleMap 은 agentKey → 실제 존재하는 station.key 로 lookup
  // 하므로, 매핑된 책상이 없으면 null. 사용자가 default ceo 책상을 지워도 안전.
  let role = null;
  const m = (s?.message || '').match(/^([a-z0-9_-]+)/i);
  if(m) role = roleMap[m[1]] || null;
  if(['planning','analyzing','waiting_approval'].includes(st)) role = roleMap['ceo'] || role;
  if(st === 'reviewing') role = roleMap['inspector'] || role;
  // 활성 outline은 *항상* 즉시 반영 (시뮬레이션 없이도 누가 일하는지 보임).
  activate(role);
  // inspector 가 아닌 active role 이 있으면 *작성자 후보* 로 기억 — 다음 reviewing 진입 시 이 사람이 검수자 옆으로 이동.
  if(role && stationByKey[role] && stationByKey[role].agentKey !== 'inspector'){
    _lastWriterRole = role;
  }
  // 시뮬레이션은 *status 전환 + critical 진입* 에서만.
  const isPivot = CRITICAL_STATUSES.has(st);
  const isTransition = st !== _prevStatus;
  // ── Reviewing 진입 / 종료 시 작성자 이동 ──
  // status='reviewing' 진입: 직전 작성자(_lastWriterRole) 캐릭터를 검수자 책상 옆으로 walk.
  // status='reviewing' 종료: 옮겨놓은 작성자들을 모두 원래 자리로 복귀.
  if(isTransition){
    if(st === 'reviewing'){
      const inspectorRole = roleMap['inspector'];
      if(_lastWriterRole && inspectorRole && _lastWriterRole !== inspectorRole){
        _walkToReviewer(_lastWriterRole, inspectorRole);
      }
    } else if(_prevStatus === 'reviewing'){
      // 검수 종료 — 옮겨놓은 모든 char 를 원위치.
      for(const r of Array.from(_movedToReviewer.keys())){
        _restoreWriterHome(r);
      }
    }
    // ── Webtoon-style banter trigger ──
    // Phase 바뀔 때 해당 phase 의 대화 script 한 variant 무작위 선택해서 시퀀스 재생.
    // 진행 중 다른 phase 진입하면 자동 중단되고 새 script 가 시작.
    _playBanterForPhase(st);
  }
  if(isTransition){
    // 상태가 바뀔 때 작업 마무리한 캐릭터들 자리 정리(work→sit).
    Object.keys(chars).forEach(k => {
      if(k !== role && anim[k].mode === 'work') sendHome(k, 'sit');
    });
    // pivot 상태 진입은 walk + 말풍선 시뮬레이션 트리거.
    if(isPivot && role){
      sendHome(role, st === 'executing' || st === 'reviewing' ? 'work' : 'sit');
    } else if(role){
      // 일반 상태는 *정적 모드만* — 캐릭터를 그 자리에서 mode만 바꿈, 걷지 않음.
      const ch = chars[role];
      if(ch){
        const cx = parseFloat(ch.style.left), cy = parseFloat(ch.style.top);
        const st_ = stationByKey[role];
        const home = st_ ? Math.abs(cx - st_.seatX) < 1 && Math.abs(cy - st_.seatY) < 1 : false;
        if(home){
          // 이미 자리에 있으면 mode만 work/sit으로 토글 (걷기 없음).
          setSprite(role, ['executing','reviewing'].includes(st) ? 'work' : 'sit');
        } else {
          // 자리를 벗어나 있으면 한 번만 복귀 — 그 이후 같은 status가 연속 와도 다시 보내지 않음.
          sendHome(role, ['executing','reviewing'].includes(st) ? 'work' : 'sit');
        }
      }
    }
    if(st === 'done'){
      // 완료 — 모두 자리 복귀 후 단체 sit.
      Object.keys(chars).forEach(k => sendHome(k, 'work'));
      const ceoRole = roleMap['ceo']; if(ceoRole) bubble(ceoRole, '완료!');
      setTimeout(() => Object.keys(chars).forEach(k => sendHome(k, 'sit')), 1800);
    }
  }
  _prevStatus = st;
}
// D. 캐릭터 컨텍스트 메뉴 — 편집 모드 X일 때만. abort/세부보기/말풍선 정리.
let _lastState = null;
function _closeCtxMenu(){
  document.querySelectorAll('.ctx-menu, .ctx-detail').forEach(el => el.remove());
}
document.addEventListener('click', e=>{
  // 메뉴 외부 클릭 → 닫기
  if(!e.target.closest('.ctx-menu') && !e.target.closest('.ctx-detail') && !e.target.closest('.char')){
    _closeCtxMenu();
  }
});
stage.addEventListener('click', e=>{
  if(_editMode) return; // 편집 모드에선 드래그용 mousedown이 우선
  const ch = e.target.closest('.char');
  if(!ch) return;
  const role = ch.dataset.role;
  if(!role) return;
  e.stopPropagation();
  _closeCtxMenu();
  const menu = document.createElement('div');
  menu.className = 'ctx-menu';
  menu.style.setProperty('--role-color', getComputedStyle(ch).getPropertyValue('--role-color') || '#A78BFA');
  const head = document.createElement('div');
  head.className = 'ctx-menu-head';
  const st = stationByKey[role] || {label:role};
  head.innerHTML = '<span class="cmh-role">'+ (st.label||role) +'</span><br><span style="color:#D7DBEA;font-size:10px;">' + role + '</span>';
  menu.appendChild(head);
  // 옵션
  const addItem = (label, danger, onClick)=>{
    const it = document.createElement('div');
    it.className = 'ctx-menu-item' + (danger?' danger':'');
    it.textContent = label;
    it.onclick = (ev)=>{ ev.stopPropagation(); onClick(); _closeCtxMenu(); };
    menu.appendChild(it);
  };
  addItem('📋 현재 작업 보기', false, ()=>{
    _showDetail(role, _lastState);
  });
  addItem('💬 말풍선 보내기', false, ()=>{
    const msg = window.prompt(role + '에게 보낼 메시지', '');
    if(msg) bubble(role, msg);
  });
  const sep = document.createElement('div'); sep.className='ctx-menu-divider'; menu.appendChild(sep);
  addItem('🛑 현재 작업 중단', true, ()=>{
    try{ vscode.postMessage({type: 'pixelOfficeCommand', cmd: 'abort', role}); }catch{}
  });
  // 위치 — 클릭한 캐릭터 위
  document.body.appendChild(menu);
  const r = ch.getBoundingClientRect();
  const mw = menu.offsetWidth, mh = menu.offsetHeight;
  let mx = r.left + r.width/2 - mw/2, my = r.top - mh - 6;
  if(my < 8) my = r.bottom + 6;
  mx = Math.max(8, Math.min(window.innerWidth - mw - 8, mx));
  menu.style.left = mx + 'px';
  menu.style.top = my + 'px';
});
function _showDetail(role, state){
  _closeCtxMenu();
  const overlay = document.createElement('div');
  overlay.className = 'ctx-detail';
  const ch = chars[role];
  if(ch) overlay.style.setProperty('--role-color', getComputedStyle(ch).getPropertyValue('--role-color') || '#A78BFA');
  const st = stationByKey[role] || {label:role};
  const close = document.createElement('button'); close.className='cd-close'; close.textContent='✕'; close.onclick=_closeCtxMenu;
  overlay.appendChild(close);
  const h = document.createElement('h3'); h.textContent = (st.label||role) + ' · ' + role;
  overlay.appendChild(h);
  const dl = document.createElement('dl');
  const add = (k,v)=>{ if(v==null||v==='') return; const dt=document.createElement('dt');dt.textContent=k;const dd=document.createElement('dd');dd.textContent=v;dl.appendChild(dt);dl.appendChild(dd); };
  add('상태', state?.status);
  add('단계', state?.currentStep);
  add('다음', state?.nextStep);
  add('메모', state?.message);
  if(state?.requirementContract){
    const c = state.requirementContract;
    add('목표', c.goal);
    add('맥락', c.context);
    add('형식', c.format);
    if(Array.isArray(c.criteria) && c.criteria.length) add('기준', c.criteria.join(' · '));
    if(Array.isArray(c.openQuestions) && c.openQuestions.length) add('미해결 질문', c.openQuestions.join(' / '));
  }
  overlay.appendChild(dl);
  if(Array.isArray(state?.recentLogs) && state.recentLogs.length){
    const logs = document.createElement('div'); logs.className='cd-logs';
    logs.innerHTML = state.recentLogs.map(l=>'<div>'+ (l||'').replace(/[<>]/g,'') +'</div>').join('');
    overlay.appendChild(logs);
  }
  document.body.appendChild(overlay);
  const ow=overlay.offsetWidth, oh=overlay.offsetHeight;
  overlay.style.left = Math.max(20, (window.innerWidth-ow)/2) + 'px';
  overlay.style.top  = Math.max(40, (window.innerHeight-oh)/2) + 'px';
}

// E. Activity ticker state — 최근 N개 행동만 ring buffer로. 너무 길어지면
// 자동으로 가장 오래된 것 drop.
const _tickerItems = [];
const TICKER_MAX = 12;
function _classifyTickerItem(text){
  if(/❌|fail|error|⚠️/i.test(text)) return 'tk-err';
  if(/✅|ok|created|edited|completed/i.test(text)) return 'tk-ok';
  if(/⚠|warn|hollow/i.test(text)) return 'tk-warn';
  return '';
}
function _renderTicker(){
  const wrap = document.getElementById('ticker');
  const track = document.getElementById('tickerTrack');
  if(_tickerItems.length === 0){ wrap.style.display = 'none'; return; }
  wrap.style.display = 'block';
  const html = _tickerItems.map(it => {
    const cls = _classifyTickerItem(it.text);
    const ag = it.agentId ? '<span class="tk-agent">'+ it.agentId +'</span>' : '';
    return '<span class="tk-item '+ cls +'">'+ ag + (it.text || '').replace(/[<>]/g,'') +'</span>';
  }).join('');
  track.innerHTML = html;
}
// ─────────────── Dual-mode message handler (refactor #D) ───────────────
// 옛 pixelOfficeUpdate/Activity 와 새 officeSnapshot 둘 다 listen. 첫 officeSnapshot
// 이 도착하면 _seenOfficeSnapshot = true 가 되고 옛 message 는 무시됨 → 안전한 점진 전환.
let _seenOfficeSnapshot = false;

// OfficeSnapshot → 옛 AgentWorkState 형태로 변환 후 기존 apply() 재사용.
// 정보 손실 최소화: roster[activeId] 에서 status/step/log 가져옴.
function _phaseToStatus(phase){
  if(phase === 'awaiting-approval') return 'waiting_approval';
  if(phase === 'reporting') return 'done';
  if(phase === 'intake') return 'analyzing';
  return phase || 'idle';
}
const ROLE_LABELS = {
  ceo:'CEO',
  planner:'기획',
  researcher:'리서치',
  designer:'디자인',
  developer:'개발',
  qa:'QA',
  inspector:'감리',
  support:'지원',
  writer:'문서',
};
const ROLE_SHORT = {
  ceo:'CEO',
  planner:'PL',
  researcher:'RS',
  designer:'UX',
  developer:'DEV',
  qa:'QA',
  inspector:'PO',
  support:'PM',
  writer:'WR',
};
function _renderRoster(roster, activeAgentId){
  const wrap = document.getElementById('rosterList');
  const count = document.getElementById('rosterCount');
  if(!wrap || !count) return;
  const list = Array.isArray(roster) ? roster : [];
  const officeShell = document.querySelector('.office-shell');
  if(officeShell) officeShell.dataset.rosterEmpty = list.length ? 'false' : 'true';
  count.textContent = String(list.length);
  if(!list.length){
    wrap.innerHTML = '<div class="roster-item"><div class="roster-copy"><strong>등록된 팀이 없습니다</strong><span class="roster-meta">회사 모드를 켜면 라인업이 표시됩니다.</span></div></div>';
    return;
  }
  wrap.innerHTML = list.map((r)=>{
    const active = r.agentId === activeAgentId;
    const role = ROLE_LABELS[r.roleCategory] || r.roleCategory || '지원';
    const short = ROLE_SHORT[r.roleCategory] || 'AG';
    return '<div class="roster-item '+(active?'active':'')+'" data-agent="'+(r.agentId||'')+'">'+
      '<div class="roster-avatar">'+short+'</div>'+
      '<div class="roster-copy">'+
        '<strong>'+(r.agentName || r.agentId || 'Agent')+'</strong>'+
        '<div class="roster-meta"><span class="roster-status"></span><span>'+role+'</span></div>'+
      '</div>'+
    '</div>';
  }).join('');
  wrap.querySelectorAll('.roster-item[data-agent]').forEach((item)=>{
    const id = item.dataset.agent;
    item.addEventListener('mouseenter', ()=>_previewAgent(id, true, item));
    item.addEventListener('mouseleave', ()=>_previewAgent(id, false, item));
  });
}
function _previewAgent(agentId, on, item){
  if(item) item.classList.toggle('preview', !!on);
  const st = findStationByAgent(agentId);
  if(!st) return;
  const desk = __deskWrap[st.key];
  const ch = chars[st.key];
  if(desk) desk.classList.toggle('preview', !!on);
  if(ch) ch.classList.toggle('preview', !!on);
}
// refactor #G-full — roster 에 있는 agent 중 desk 가 없는 경우 자동 생성.
function _roleCategoryToCharRow(role){
  switch(role){
    case 'ceo': return 0;
    case 'planner': return 1;
    case 'researcher': return 2;
    case 'designer': return 3;
    case 'developer': return 4;
    case 'qa': return 5;
    case 'inspector': return 6;
    case 'support': return 7;
    case 'writer': return 1;
    default: return 0;
  }
}
function _autoCreateDeskForAgent(r){
  // 기존 _addNewDesk 와 같은 메커니즘이지만 agentKey/label/charRow/sprite 를 roster 정보에 맞춰 채움.
  const id = 'desk_'+(__nextDeskN++);
  // 위치: 현재 station 수에 따라 격자 배치. 너무 빽빽하면 겹치지만 사용자가 드래그로 정리 가능.
  const slotN = stations.length;
  const cols = 6;
  const baseX = 36 + (slotN % cols) * 112;
  const baseY = 28 + Math.floor(slotN / cols) * 95;
  const isBoss = r.roleCategory === 'ceo';
  const st = {
    key: id,
    agentKey: r.agentId,
    label: r.agentName || r.agentId,
    charRow: _roleCategoryToCharRow(r.roleCategory),
    deskSprite: isBoss ? 'astra-desk-exec' : 'astra-desk-work',
    face: 'R',
    boss: isBoss,
    deskX: baseX, deskY: baseY, deskW: isBoss ? 136 : 112,
    seatX: baseX + 4, seatY: baseY + 36,
    dock: [baseX + 32, baseY + 80],
    roam: [[baseX - 20, baseY + 120],[baseX + 60, baseY + 100]],
  };
  stations.push(st);
  _rebuildStationIndex();
  buildStation(st);
}
function _ensureRosterDesks(roster){
  if(!Array.isArray(roster)) return;
  for(const r of roster){
    if(!r || !r.agentId) continue;
    if(_autoDeskedFor.has(r.agentId)) continue; // 이미 한번 처리됨 (생성했든 매핑됐든)
    // 직접 매핑된 station 있는가?
    const direct = stations.find(s => s.agentKey === r.agentId);
    if(direct){ _autoDeskedFor.add(r.agentId); continue; }
    // alias 로 잡히는가? (writer→planner 등 옛 이름)
    const aliased = AGENT_ALIASES[r.agentId];
    if(aliased && stations.find(s => s.agentKey === aliased)){
      _autoDeskedFor.add(r.agentId);
      continue;
    }
    // 아무 데도 없음 — 자동 생성.
    _autoCreateDeskForAgent(r);
    _autoDeskedFor.add(r.agentId);
  }
}

function applyFromSnapshot(snap){
  if(!snap) return;
  _latestSnapshot = snap;
  const roster = Array.isArray(snap.roster) ? snap.roster : [];
  const orderedRoster = _pipelineRosterOrder(roster, snap.pipeline);
  _renderRoster(orderedRoster, snap.activeAgentId);
  _syncOfficeLayout(snap.pipeline, orderedRoster);
  if(_layoutSource!=='roster' || _hasSavedLayout) _ensureRosterDesks(orderedRoster);
  _renderPipelineDecor(snap.pipeline, orderedRoster);
  const active = (snap.activeAgentId && roster.find(a => a.agentId === snap.activeAgentId)) || roster[0];
  const activeStage = snap.pipeline && Array.isArray(snap.pipeline.stages)
    ? snap.pipeline.stages.find(stg => stg.status === 'active')
    : null;
  const stageCount = snap.pipeline && Array.isArray(snap.pipeline.stages) ? snap.pipeline.stages.length : 0;
  const doneCount = snap.pipeline && Array.isArray(snap.pipeline.stages)
    ? snap.pipeline.stages.filter(stg => stg.status === 'done').length
    : 0;
  const synthetic = {
    agentId: snap.activeAgentId || (active && active.agentId) || 'main',
    agentName: (active && active.agentName) || 'Agent',
    status: (active && active.status) || _phaseToStatus(snap.phase),
    currentTask: snap.task && snap.task.goal,
    currentStep: (active && active.currentStep) || (activeStage && activeStage.label),
    // apply() 의 message regex 가 첫 토큰을 agentId 로 추출 — activeAgentId 그대로 넘김.
    message: snap.activeAgentId || '',
    recentLogs: (active && active.lastLog) ? [active.lastLog] : [],
    progress: stageCount ? (doneCount / stageCount) : 0,
    pipelineStages: snap.pipeline && snap.pipeline.stages,
    needUserInput: (snap.awaiting && snap.awaiting.kind === 'clarification') ? snap.awaiting.questions : undefined,
    awaitingApproval: (snap.awaiting && snap.awaiting.kind === 'approval') ? snap.awaiting.questions[0] : undefined,
    requirementContract: snap.task,
    updatedAt: snap.updatedAt,
  };
  apply(synthetic);
  // 풍선 시드 — routeBubble 의 critical filter 그대로 통과.
  if(Array.isArray(snap.newBubbles)){
    snap.newBubbles.forEach(b => routeBubble({ agentId: b.agentId, text: b.text, type: b.type }));
  }
  // Activity — snapshot 이 full ring buffer 를 통째로 보내므로 ticker 를 매번 재구성.
  if(Array.isArray(snap.activity)){
    _tickerItems.length = 0;
    const tail = snap.activity.slice(-TICKER_MAX);
    for(const it of tail){
      _tickerItems.push({ agentId: it.agentId, text: it.text });
    }
    _renderTicker();
    // 가장 최근 activity 항목 한 개에 대해 머리 위 말풍선 (옛 동작 흉내).
    const last = tail[tail.length - 1];
    if(last){
      const r = roleMap[last.agentId];
      if(r) _bubbleFromLog(r, last.text);
    }
  }
}

window.addEventListener('message',e=>{
  const d = e.data;
  if(!d) return;
  if(d.type === 'officeSnapshot'){
    _seenOfficeSnapshot = true;
    applyFromSnapshot(d.value);
    return;
  }
  // 옛 path — 새 snapshot 을 받기 시작했다면 무시 (백엔드가 양쪽 다 emit 중인 마이그레이션 기간).
  if(_seenOfficeSnapshot) return;
  if(d.type === 'pixelOfficeUpdate'){
    const v = d.value;
    if(!v) return;
    if(v.state) apply(v.state);
    if(Array.isArray(v.bubbles)) v.bubbles.forEach(routeBubble);
  } else if(d.type === 'pixelOfficeActivity'){
    const v = d.value;
    if(!v || !Array.isArray(v.items)) return;
    for(const it of v.items){
      _tickerItems.push(it);
      if(_tickerItems.length > TICKER_MAX) _tickerItems.shift();
      // 작업중 캐릭터 머리 위 말풍선 — 감리는 키워드, 그 외는 짧은 한 줄.
      const r = roleMap[it.agentId];
      if(r) _bubbleFromLog(r, it.text);
    }
    _renderTicker();
  }
});
try{vscode.postMessage({type:'getPixelOfficeState'})}catch{}

// ─────────────── Layout Editor ───────────────
// 사용자가 ✏️ 편집 버튼을 누르면 편집 모드 진입. 책상/캐릭터/소품을 드래그로
// 위치 조정 가능. 4px snap. 저장 시 백엔드 workspace state에 영구 저장. 다음
// 패널 오픈 때 자동 복원. 디폴트로 복귀 버튼도 제공.
let _editMode=false, _drag=null, _dragDX=0, _dragDY=0, _snapshotBeforeEdit=null, _selected=null;

function _snapshotLayout(){
  // 새 schema v2: cells 가 단순 좌표 패치가 아니라 stations 전체 정의를 담는다.
  // (책상 추가/제거가 가능하니 default 와의 delta 로는 표현 불가.) v1 호환을 위해
  // _restoreLayout 이 양쪽 모두 처리.
  return {
    schema: 2,
    cells: stations.map(st=>{
      const ch = chars[st.key];
      return {
        roleKey: st.key,
        agentKey: st.agentKey || '',
        label: st.label || '',
        charRow: st.charRow ?? 0,
        deskSprite: st.deskSprite || 'astra-desk-work',
        face: st.face || 'R',
        boss: !!st.boss,
        noChar: !!st.noChar,
        dock: st.dock,
        roam: st.roam,
        deskX: parseFloat(__deskWrap[st.key].style.left),
        deskY: parseFloat(__deskWrap[st.key].style.top),
        deskW: parseFloat(__deskWrap[st.key].style.width),
        deskRot: parseFloat(__deskWrap[st.key].dataset.rot || '0'),
        deskZ: parseFloat(__deskWrap[st.key].dataset.z || '0'),
        seatX: ch ? parseFloat(ch.style.left) : (st.seatX ?? 0),
        seatY: ch ? parseFloat(ch.style.top)  : (st.seatY ?? 0),
        charRot: ch ? parseFloat(ch.dataset.rot || '0') : 0,
        charZ:   ch ? parseFloat(ch.dataset.z || '0')   : 0,
      };
    }),
    objs: Array.from(stage.querySelectorAll('img.obj')).map(el=>({
      id: el.dataset.objId,
      name: el.dataset.objName,
      x: parseFloat(el.style.left),
      y: parseFloat(el.style.top),
      w: el.dataset.objW ? parseFloat(el.dataset.objW) : undefined,
      rot: parseFloat(el.dataset.rot || '0'),
      z: parseFloat(el.dataset.z || '0'),
    })),
  };
}

// stage 에 그려진 모든 desk/char/obj DOM 제거 (벽 창문 제외). 레이아웃 리빌드 직전에 호출.
function _clearStage(){
  Array.from(stage.querySelectorAll('.desk,.char,img.obj')).forEach(el=>el.remove());
  Object.keys(__deskWrap).forEach(k=>delete __deskWrap[k]);
  Object.keys(chars).forEach(k=>delete chars[k]);
  Object.keys(anim).forEach(k=>delete anim[k]);
  stations = [];
  // refactor #G-full: layout reset 시 auto-desk 결정 기록도 초기화 →
  // 다음 snapshot 도착 시 roster 기반으로 다시 평가.
  _autoDeskedFor.clear();
  _clearPipelineDecor();
}

function _applyRot(el, rot){
  if(!el) return;
  const r = typeof rot==='number' ? rot : 0;
  el.dataset.rot = String(r);
  el.style.transform = r === 0 ? '' : ('rotate('+r+'deg)');
}
function _applyZ(el, z){
  if(!el) return;
  if(typeof z !== 'number') return;
  el.dataset.z = String(z);
  el.style.zIndex = z === 0 ? '' : String(z);
}
// v2 schema 또는 cell 에 desk 정의 필드가 있으면 stage 를 통째로 재구축.
// v1 (옛 포맷) 이면 좌표만 패치하는 in-place 갱신.
function _isV2Snap(snap){
  if(!snap || !Array.isArray(snap.cells)) return false;
  if(snap.schema === 2) return true;
  return snap.cells.some(c => c && (typeof c.deskSprite === 'string' || typeof c.agentKey === 'string' || typeof c.charRow === 'number'));
}
function _restoreLayout(snap){
  if(!snap) return;
  if(_isV2Snap(snap)){
    _clearStage();
    (snap.cells||[]).forEach(c=>{
      const st = {
        key: c.roleKey,
        agentKey: c.agentKey || '',
        label: c.label || c.roleKey,
        charRow: typeof c.charRow === 'number' ? c.charRow : 0,
        deskSprite: c.deskSprite || 'astra-desk-work',
        face: c.face || 'R',
        boss: !!c.boss,
        noChar: !!c.noChar,
        dock: Array.isArray(c.dock) ? c.dock : [c.deskX+32, c.deskY+80],
        roam: Array.isArray(c.roam) ? c.roam : [[c.deskX, c.deskY+120],[c.deskX+60, c.deskY+100]],
        deskX: c.deskX, deskY: c.deskY, deskW: c.deskW || 112,
        seatX: c.seatX, seatY: c.seatY,
      };
      stations.push(st);
      const m = String(st.key).match(/^desk_(\\d+)$/);
      if(m){ const n = parseInt(m[1],10); if(n >= __nextDeskN) __nextDeskN = n + 1; }
    });
    _rebuildStationIndex();
    stations.forEach(buildStation);
    (snap.cells||[]).forEach(c=>{
      const wrap=__deskWrap[c.roleKey], ch=chars[c.roleKey];
      if(wrap){ _applyRot(wrap, c.deskRot); _applyZ(wrap, c.deskZ); }
      if(ch){ _applyRot(ch, c.charRot); _applyZ(ch, c.charZ); }
    });
    (snap.objs||[]).forEach(o=>{
      const el = addImg(o.name, o.x, o.y, o.w);
      if(o.id){ el.dataset.objId = o.id; }
      _applyRot(el, o.rot); _applyZ(el, o.z);
    });
    return;
  }
  // v1 — 옛 포맷, in-place patch.
  (snap.cells||[]).forEach(c=>{
    const wrap=__deskWrap[c.roleKey];
    if(wrap){
      if(typeof c.deskX==='number') wrap.style.left=c.deskX+'px';
      if(typeof c.deskY==='number') wrap.style.top=c.deskY+'px';
      if(typeof c.deskW==='number') wrap.style.width=c.deskW+'px';
      _applyRot(wrap, c.deskRot);
      _applyZ(wrap, c.deskZ);
    }
    const ch=chars[c.roleKey];
    if(ch){
      if(typeof c.seatX==='number'){ch.style.left=c.seatX+'px';ch.dataset.homeX=c.seatX;}
      if(typeof c.seatY==='number'){ch.style.top=c.seatY+'px';ch.dataset.homeY=c.seatY;}
      _applyRot(ch, c.charRot);
      _applyZ(ch, c.charZ);
    }
    const st=stationByKey[c.roleKey];
    if(st){
      if(typeof c.deskX==='number') st.deskX=c.deskX;
      if(typeof c.deskY==='number') st.deskY=c.deskY;
      if(typeof c.seatX==='number') st.seatX=c.seatX;
      if(typeof c.seatY==='number') st.seatY=c.seatY;
    }
  });
  (snap.objs||[]).forEach(o=>{
    let el=null;
    if(o.id) el=stage.querySelector('img.obj[data-obj-id="'+o.id+'"]');
    if(!el && o.name){
      // id 없으면 name으로 첫번째 매칭 — legacy 데이터 호환.
      el=stage.querySelector('img.obj[data-obj-name="'+o.name+'"]');
    }
    if(!el) return;
    if(typeof o.x==='number') el.style.left=o.x+'px';
    if(typeof o.y==='number') el.style.top=o.y+'px';
    if(typeof o.w==='number'){ el.style.width=o.w+'px'; el.dataset.objW=String(o.w); }
    _applyRot(el, o.rot);
    _applyZ(el, o.z);
  });
}

function _setEdit(on){
  _editMode=!!on;
  document.body.dataset.editMode = _editMode?'true':'false';
  document.getElementById('editToolbar').style.display = _editMode?'flex':'none';
  document.getElementById('editBtn').textContent = _editMode?'편집 종료':'Customize';
  if(_editMode){
    _snapshotBeforeEdit = _snapshotLayout();
  } else {
    _snapshotBeforeEdit = null;
    // 편집 종료 시 selected 강조 해제.
    if(_selected){ _selected.classList.remove('selected'); _selected = null; }
  }
  _onSelectionChanged();
}

// ── 선택 변화 시 호출 — 속성 패널 / 삭제 버튼 상태 sync ──
function _onSelectionChanged(){
  const panel = document.getElementById('propPanel');
  const delBtn = document.getElementById('deleteSelBtn');
  if(!_editMode || !_selected){
    panel.classList.remove('show'); panel.innerHTML='';
    if(delBtn) delBtn.disabled = true;
    return;
  }
  // 캐릭터를 선택해도 그 책상의 속성을 편집한다 (캐릭터와 책상은 한 쌍).
  let targetForProps = _selected;
  if(targetForProps.classList.contains('char')){
    const role = Object.keys(chars).find(k=>chars[k]===targetForProps);
    if(role && __deskWrap[role]) targetForProps = __deskWrap[role];
  }
  if(targetForProps.classList.contains('desk')){
    if(delBtn) delBtn.disabled = false;
    _renderDeskProps(targetForProps);
  } else if(_selected.classList.contains('obj')){
    if(delBtn) delBtn.disabled = false;
    _renderObjProps(_selected);
  } else {
    panel.classList.remove('show'); panel.innerHTML='';
    if(delBtn) delBtn.disabled = true;
  }
}

function _renderDeskProps(deskEl){
  const role = deskEl.dataset.role;
  const st = stationByKey[role]; if(!st) return;
  const panel = document.getElementById('propPanel');
  panel.classList.add('show');
  // 에이전트 매핑 dropdown.
  const agentOpts = AGENT_CHOICES.map(a=>'<option value="'+a+'"'+(a===(st.agentKey||'')?' selected':'')+'>'+(a||'(매핑 없음)')+'</option>').join('');
  // 책상 sprite picker.
  const deskOpts = DESK_SPRITE_CHOICES.map(s=>'<option value="'+s+'"'+(s===st.deskSprite?' selected':'')+'>'+_spriteLabel(s)+'</option>').join('');
  // charRow 썸네일 picker (idle-r<n>-f0.png).
  let thumbs='';
  for(let r=0;r<8;r++){
    thumbs += '<div class="pp-thumb'+(r===st.charRow?' active':'')+'" data-charrow="'+r+'" title="row '+r+'"><img src="'+png('idle-r'+r+'-f0')+'"></div>';
  }
  const hasChar = !!chars[role];
  panel.innerHTML =
    '<h4>책상 속성</h4>'+
    '<div class="pp-row"><label>라벨</label><input id="ppLabel" value="'+(st.label||'').replace(/"/g,'&quot;')+'"></div>'+
    '<div class="pp-row"><label>에이전트 매핑</label><select id="ppAgent">'+agentOpts+'</select></div>'+
    '<div class="pp-row"><label>책상 sprite</label><select id="ppDesk">'+deskOpts+'</select></div>'+
    (hasChar ? '' : '<div class="pp-row"><button id="ppAddChar" style="width:100%;padding:6px;background:rgba(16,185,129,.22);border:1px solid rgba(16,185,129,.55);color:#F1F4FB;border-radius:4px;cursor:pointer;font-size:11px">+ 이 책상에 캐릭터 추가</button></div>')+
    '<div class="pp-row"><label>착석 캐릭터 (row 0~7)</label><div class="pp-thumbs" id="ppThumbs">'+thumbs+'</div></div>'+
    '<div class="pp-row"><label>방향 (앉은 face)</label><select id="ppFace">'+
      '<option value="L"'+(st.face==='L'?' selected':'')+'>← Left</option>'+
      '<option value="R"'+(st.face==='R'?' selected':'')+'>Right →</option>'+
      '<option value="U"'+(st.face==='U'?' selected':'')+'>↑ Up (뒷모습)</option>'+
      '<option value="D"'+(st.face==='D'?' selected':'')+'>↓ Down (정면)</option>'+
    '</select></div>';
  // 핸들러
  panel.querySelector('#ppLabel').oninput = (ev)=>{
    st.label = ev.target.value;
    const lbl = deskEl.querySelector('.label'); if(lbl) lbl.textContent = st.label;
  };
  panel.querySelector('#ppAgent').onchange = (ev)=>{
    st.agentKey = ev.target.value || '';
    // CSS data-role 색깔은 agentKey 기준 — 매핑 변경 시 swap.
    if(st.agentKey){ deskEl.dataset.agent = st.agentKey; if(chars[role]) chars[role].dataset.agent = st.agentKey; }
    else { delete deskEl.dataset.agent; if(chars[role]) delete chars[role].dataset.agent; }
  };
  panel.querySelector('#ppDesk').onchange = (ev)=>{
    st.deskSprite = ev.target.value;
    const img = deskEl.querySelector('img'); if(img) img.src = png(st.deskSprite);
  };
  panel.querySelectorAll('.pp-thumb').forEach(t=>{
    t.onclick = ()=>{
      const r = parseInt(t.dataset.charrow,10);
      st.charRow = r;
      if(anim[role]){ anim[role].row = r; }
      const ch = chars[role]; if(ch){ ch.dataset.row = r; const img=ch.querySelector('img'); if(img) img.src = png('idle-r'+r+'-f0'); }
      panel.querySelectorAll('.pp-thumb').forEach(x=>x.classList.toggle('active', x===t));
    };
  });
  panel.querySelector('#ppFace').onchange = (ev)=>{
    st.face = ev.target.value;
    if(anim[role]) anim[role].face = st.face;
    // 현재 mode 기준 sprite 즉시 다시 그리기 — U/D 도 한 번에 반영.
    const a = anim[role]; if(a) setSprite(role, a.mode || 'sit', 0, 0);
  };
  const addCharBtn = panel.querySelector('#ppAddChar');
  if(addCharBtn){
    addCharBtn.onclick = ()=>{
      st.noChar = false;
      addChar(st);
      _renderDeskProps(deskEl); // 패널 재렌더 — "캐릭터 추가" 버튼 제거.
    };
  }
}

function _renderObjProps(el){
  const panel = document.getElementById('propPanel');
  panel.classList.add('show');
  const name = el.dataset.objName || '';
  const w = el.dataset.objW || '';
  const opts = PROP_SPRITE_CHOICES.map(s=>'<option value="'+s+'"'+(s===name?' selected':'')+'>'+_spriteLabel(s)+'</option>').join('');
  panel.innerHTML =
    '<h4>가구 속성</h4>'+
    '<div class="pp-row"><label>sprite</label><select id="ppObjName">'+opts+'</select></div>'+
    '<div class="pp-row"><label>너비 (px, 비우면 원본)</label><input id="ppObjW" type="number" value="'+w+'" placeholder="원본"></div>';
  panel.querySelector('#ppObjName').onchange = (ev)=>{
    el.dataset.objName = ev.target.value;
    el.src = png(ev.target.value);
  };
  panel.querySelector('#ppObjW').oninput = (ev)=>{
    const v = ev.target.value.trim();
    if(v === ''){ el.style.removeProperty('width'); delete el.dataset.objW; }
    else { el.style.width = parseFloat(v)+'px'; el.dataset.objW = String(parseFloat(v)); }
  };
}

// ── 신규 책상 추가 ──
function _addNewDesk(){
  const id = 'desk_'+(__nextDeskN++);
  // 기본 위치: stage 중앙 근처, 다른 책상과 안 겹치게 살짝 오프셋.
  const baseX = 280 + ((__nextDeskN%5)*16);
  const baseY = 260 + ((__nextDeskN%5)*16);
  const st = {
    key: id, agentKey: '', label: '새 책상', charRow: 0, deskSprite: 'astra-desk-work', face: 'R',
    boss: false,
    deskX: baseX, deskY: baseY, deskW: 112,
    seatX: baseX+4, seatY: baseY+36,
    dock: [baseX+32, baseY+80],
    roam: [[baseX-20, baseY+120],[baseX+60, baseY+100]],
  };
  stations.push(st); _rebuildStationIndex();
  buildStation(st);
  // 새 책상을 자동 선택.
  if(_selected) _selected.classList.remove('selected');
  _selected = __deskWrap[id]; _selected.classList.add('selected');
  _onSelectionChanged();
}

// ── 신규 프랍 추가 — sprite picker 모달 ──
function _openPropPicker(){
  const overlay = document.createElement('div');
  overlay.className = 'prop-picker';
  const box = document.createElement('div');
  box.className = 'prop-picker-box';
  box.innerHTML = '<h3>가구 추가</h3>'+
    '<div class="prop-picker-grid">'+
      PROP_SPRITE_CHOICES.map(n=>'<div class="prop-pick" data-name="'+n+'"><img src="'+png(n)+'"><div class="pp-name">'+_spriteLabel(n)+'</div></div>').join('')+
    '</div>';
  overlay.appendChild(box);
  overlay.onclick = (e)=>{ if(e.target === overlay) overlay.remove(); };
  box.querySelectorAll('.prop-pick').forEach(p=>{
    p.onclick = ()=>{
      const name = p.dataset.name;
      // stage 중앙 근처에 배치.
      const x = 300 + ((__nextObjN%4)*12);
      const y = 280 + ((__nextObjN%4)*12);
      const el = addImg(name, x, y);
      overlay.remove();
      // 자동 선택.
      if(_selected) _selected.classList.remove('selected');
      _selected = el; _selected.classList.add('selected');
      _onSelectionChanged();
    };
  });
  document.body.appendChild(overlay);
}

// ── 선택 항목 삭제 ──
// 캐릭터 / 책상 / 프랍 각각 분리해서 처리:
//   · 캐릭터 선택 → 캐릭터만 삭제 (책상은 유지). 속성 패널에서 "+ 캐릭터" 로 재추가 가능.
//   · 책상 선택 → 책상 + 캐릭터 모두 삭제 (station 자체 제거).
//   · 프랍 선택 → 프랍 삭제.
function _deleteSelected(){
  if(!_editMode || !_selected) return;
  if(_selected.classList.contains('char')){
    // VS Code webview 는 window.confirm() 을 지원하지 않아 (silently false 반환)
    // 이전 코드에서 if(!confirm(...)) return 패턴이 즉시 return 되어 삭제가 안 됐다.
    // 편집 세션은 Cancel 버튼으로 되돌릴 수 있으니 확인 다이얼로그 없이 바로 삭제.
    let role = Object.keys(chars).find(k=>chars[k]===_selected);
    if(!role) role = _selected.dataset.role || '';
    const st = role ? stationByKey[role] : null;
    _selected.remove();
    if(role){ delete chars[role]; delete anim[role]; if(st) st.noChar = true; }
    _selected = null;
    _onSelectionChanged();
    return;
  }
  if(_selected.classList.contains('desk')){
    const role = _selected.dataset.role;
    _selected.remove();
    if(chars[role]) chars[role].remove();
    delete __deskWrap[role]; delete chars[role]; delete anim[role];
    const idx = stations.findIndex(s=>s.key===role);
    if(idx>=0) stations.splice(idx,1);
    _rebuildStationIndex();
    _selected = null;
    _onSelectionChanged();
    return;
  }
  if(_selected.classList.contains('obj')){
    _selected.remove();
    _selected = null;
    _onSelectionChanged();
    return;
  }
}

function _findDraggable(el){
  while(el && el !== stage){
    if(el.classList){
      if(el.classList.contains('obj')) return el;
      if(el.classList.contains('char')) return el;
      if(el.classList.contains('desk')) return el;
    }
    el = el.parentElement;
  }
  return null;
}

stage.addEventListener('mousedown', e=>{
  if(!_editMode) return;
  const target = _findDraggable(e.target);
  // 빈 공간 클릭 → 선택 해제.
  if(!target){
    if(_selected){ _selected.classList.remove('selected'); _selected=null; _onSelectionChanged(); }
    return;
  }
  e.preventDefault();
  // 선택 표시 갱신 — 한 번에 하나만 selected. 회전 키가 이 객체에 적용됨.
  if(_selected && _selected !== target) _selected.classList.remove('selected');
  _selected = target;
  target.classList.add('selected');
  _onSelectionChanged();
  _drag = target;
  const rect = stage.getBoundingClientRect();
  const tx = parseFloat(target.style.left)||0;
  const ty = parseFloat(target.style.top)||0;
  _dragDX = ((e.clientX - rect.left) / _stageScale) - tx;
  _dragDY = ((e.clientY - rect.top) / _stageScale) - ty;
  target.classList.add('dragging');
});

// 키보드 — 편집 모드에서 선택된 객체에 적용.
//   R          : 시계방향 90도 회전
//   Shift+R    : 반시계방향 90도 회전
//   ]          : 레이어 위로 (z-index +1)
//   [          : 레이어 아래로 (z-index -1)
function _bringLayer(el, delta){
  const cur = parseFloat(el.dataset.z || '0');
  const next = Math.max(-99, Math.min(99, cur + delta));
  el.dataset.z = String(next);
  el.style.zIndex = String(next);
}
document.addEventListener('keydown', e=>{
  if(!_editMode || !_selected) return;
  const tag = (e.target && e.target.tagName) || '';
  if(tag === 'INPUT' || tag === 'TEXTAREA') return;
  const key = e.key.toLowerCase();
  if(key === 'r'){
    e.preventDefault();
    const delta = e.shiftKey ? -90 : 90;
    const cur = parseFloat(_selected.dataset.rot || '0');
    const next = ((cur + delta) % 360 + 360) % 360;
    _selected.dataset.rot = String(next);
    _selected.style.transform = next === 0 ? '' : ('rotate(' + next + 'deg)');
    return;
  }
  if(key === ']'){ e.preventDefault(); _bringLayer(_selected, +1); return; }
  if(key === '['){ e.preventDefault(); _bringLayer(_selected, -1); return; }
});
document.addEventListener('mousemove', e=>{
  if(!_editMode || !_drag) return;
  const rect = stage.getBoundingClientRect();
  let x = ((e.clientX - rect.left) / _stageScale) - _dragDX;
  let y = ((e.clientY - rect.top) / _stageScale) - _dragDY;
  // 4px 격자 snap
  x = Math.round(x/4)*4;
  y = Math.round(y/4)*4;
  // stage 경계 안에 가두기
  const sw = stage.offsetWidth, sh = stage.offsetHeight;
  const tw = _drag.offsetWidth || 0, th = _drag.offsetHeight || 0;
  x = Math.max(0, Math.min(sw - tw, x));
  y = Math.max(0, Math.min(sh - th, y));
  _drag.style.left = x+'px';
  _drag.style.top = y+'px';
});
document.addEventListener('mouseup', ()=>{
  if(_drag){ _drag.classList.remove('dragging'); }
  if(_editMode && _drag && _drag.classList){
    if(_drag.classList.contains('desk')){
      // 책상만 새 좌표 저장. 캐릭터는 *그대로 둔다* (분리).
      const role = _drag.dataset.role;
      const st = stationByKey[role];
      if(st){
        st.deskX = parseFloat(_drag.style.left);
        st.deskY = parseFloat(_drag.style.top);
      }
    } else if(_drag.classList.contains('char')){
      // 캐릭터를 옮긴 경우 — 그 자리를 새 home으로.
      const role = Object.keys(chars).find(k=>chars[k]===_drag);
      if(role){
        const st = stationByKey[role];
        const nx = parseFloat(_drag.style.left), ny = parseFloat(_drag.style.top);
        if(st){ st.seatX = nx; st.seatY = ny; }
        _drag.dataset.homeX = nx;
        _drag.dataset.homeY = ny;
      }
    }
  }
  _drag = null;
});

document.getElementById('editBtn').addEventListener('click', ()=>{
  _setEdit(!_editMode);
});
document.getElementById('layerUpBtn').addEventListener('click', ()=>{
  if(_selected) _bringLayer(_selected, +1);
});
document.getElementById('layerDownBtn').addEventListener('click', ()=>{
  if(_selected) _bringLayer(_selected, -1);
});
document.getElementById('saveBtn').addEventListener('click', ()=>{
  const layout = _snapshotLayout();
  try{ vscode.postMessage({type:'savePixelOfficeLayout', value: layout}); }catch{}
  _setEdit(false);
});
document.getElementById('cancelBtn').addEventListener('click', ()=>{
  // 편집 시작 시 찍어둔 스냅샷으로 되돌리기.
  if(_snapshotBeforeEdit) _restoreLayout(_snapshotBeforeEdit);
  _setEdit(false);
});
document.getElementById('resetBtn').addEventListener('click', ()=>{
  // window.confirm 은 VS Code webview 에서 동작 안 함 — 그냥 reset 트리거.
  // 사용자 안전망: 편집모드 외에서 reset 누르면 reload 되니, 저장된 layout 만 날아가고 직전 편집 분은 영향 없음.
  try{ vscode.postMessage({type:'resetPixelOfficeLayout'}); }catch{}
});
document.getElementById('addDeskBtn').addEventListener('click', _addNewDesk);
document.getElementById('addPropBtn').addEventListener('click', _openPropPicker);
document.getElementById('deleteSelBtn').addEventListener('click', _deleteSelected);
// Delete / Backspace 키도 같은 동작 — 단 입력 필드 포커스 시 무시.
document.addEventListener('keydown', e=>{
  if(!_editMode) return;
  const tag = (e.target && e.target.tagName) || '';
  if(tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
  if(e.key === 'Delete' || e.key === 'Backspace'){ e.preventDefault(); _deleteSelected(); }
});

// 저장된 layout 로드 요청 + 응답 처리.
window.addEventListener('message', e=>{
  const d = e.data;
  if(!d || typeof d !== 'object') return;
  if(d.type === 'pixelOfficeLayoutLoaded'){
    const saved = d.value && !_looksLikeLegacyShowroomLayout(d.value) ? d.value : null;
    _hasSavedLayout = !!saved;
    if(saved) {
      _layoutSource = 'saved';
      _restoreLayout(saved);
    } else if(_latestSnapshot) {
      _syncOfficeLayout(_latestSnapshot.pipeline, _pipelineRosterOrder(_latestSnapshot.roster || [], _latestSnapshot.pipeline));
      _renderPipelineDecor(_latestSnapshot.pipeline, _latestSnapshot.roster || []);
    }
  }
  if(d.type === 'pixelOfficeLayoutSaved'){
    if(d.value && !d.value.reset) {
      _hasSavedLayout = true;
      _layoutSource = 'saved';
    }
    if(d.value && d.value.reset){
      // 디폴트로 리셋된 경우 — 페이지를 재로딩해서 코드 기본값으로 복귀.
      location.reload();
    }
  }
});
try{ vscode.postMessage({type:'getPixelOfficeLayout'}); }catch{}

`;

export function officeRuntimeJs(derivedBase: string): string {
  // 추출본은 이미 `<script>(function(){` 로 시작 (원본 line 4002). closing 만 붙임.
  return OFFICE_RUNTIME_JS_TEMPLATE.replace('${assets.derivedBase}', derivedBase) + '})();<\/script>';
}
