<template>
  <div class="map-editor-page">
    <EditTopBar
      :can-undo="history.canUndo.value"
      :can-redo="history.canRedo.value"
      :saving="saving"
      :view-filter="viewFilter"
      @cmd="onTopBarCmd"
    />

    <div class="me-body">
      <EditToolRail
        :active-tool="editTool.activeTool.value"
        @select-tool="onSelectTool"
        @open-ai="openAiDialog"
      />

      <div class="me-center">
        <Workspace3D
          ref="workspaceRef"
          :host="vrHost"
          :scope="auth.scope ?? 'Platform'"
          :token="auth.token ?? ''"
          :read-only="false"
          :embed-ui="false"
          :canvas-only="true"
          :hide-toolbar="true"
          @pick="onCanvasPick"
          @select="onCanvasSelect"
        />
      </div>

      <EditPropertyPanel
        :selection-count="selection.count.value"
        :primary="primary"
        :fields="primaryFields"
        :status="primaryStatus"
        :methods="primaryMethods"
        :loading="propertyLoading"
        :layers="knownLayers"
        :defaults="defaults"
        :layer-rows="layerRows"
        @rename="onRename"
        @change-layer="onChangeLayer"
        @set-field="onSetField"
        @add-field="onAddField"
        @exec-action="onExecAction"
        @save-defaults="onSaveDefaults"
        @layer-toggle="onLayerToggle"
        @add-layer="onAddLayer"
        @delete="deleteSelection"
      />
    </div>

    <EditStatusBar
      :selected-count="selection.count.value"
      :mouse-x="mouseXY.x"
      :mouse-y="mouseXY.y"
      :tool-label="editTool.toolLabel.value"
      :snap-on="snapAny"
      :undo-size="history.undoStack.value.length"
      :redo-size="history.redoStack.value.length"
      :connected="streamConnected"
    />

    <AiGenerateDialog
      v-model="aiDialogOpen"
      :configured="aiConfigured"
      :default-bounds="defaultAiBounds"
      :default-layer="defaults.site.layer"
      @generated="onAiGenerated"
    />

    <ProjectBrowseDialog
      v-model="projectBrowseOpen"
      :mode="projectBrowseMode"
      :initial-path="projectBrowseInitial"
      @confirm="onProjectBrowseConfirm"
    />

    <!--
      图层显示切换对话框：列出所有已知图层，每行一个 visible/selectable 开关 +
      「设为当前默认层」按钮。本面板与右侧 EditPropertyPanel 的图层卡片共享同一份
      layerRows，操作后双向同步；defaults.site/track.layer 决定下次创建对象的落点。
      备注：画布渲染本身受 SimpleLite 端 LayerToShow 控制，要做"画布也跟着隐藏"
      还需后端补 REST API；当前 toggle 只影响前端面板筛选。
    -->
    <el-dialog v-model="layerVisibilityDialogOpen" title="切换显示图层" width="520px">
      <el-table :data="layerRows" size="small" style="width: 100%">
        <el-table-column prop="name" label="图层" min-width="120" />
        <el-table-column label="可见" width="80">
          <template #default="{ row }">
            <el-switch v-model="row.visible" />
          </template>
        </el-table-column>
        <el-table-column label="可选" width="80">
          <template #default="{ row }">
            <el-switch v-model="row.selectable" />
          </template>
        </el-table-column>
        <el-table-column label="颜色" width="100">
          <template #default="{ row }">
            <el-color-picker v-model="row.color" size="small" />
          </template>
        </el-table-column>
        <el-table-column label="默认层" width="110">
          <template #default="{ row }">
            <el-button
              size="small"
              :type="defaults.site.layer === row.name ? 'primary' : 'default'"
              @click="setDefaultLayer(row.name)"
            >
              {{ defaults.site.layer === row.name ? '当前默认' : '设为默认' }}
            </el-button>
          </template>
        </el-table-column>
      </el-table>
      <template #footer>
        <el-button @click="layerVisibilityDialogOpen = false">关闭</el-button>
        <el-button type="primary" @click="promptCreateLayer">新建图层…</el-button>
      </template>
    </el-dialog>

    <input
      ref="fileInputRef"
      type="file"
      style="display:none"
      :accept="fileAcceptHint"
      @change="onFilePicked"
    />
  </div>
</template>

<script setup lang="ts">
import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import Workspace3D from '@/components/Workspace3D.vue'
import EditTopBar, { type ViewFilterState } from '@/components/map-editor/EditTopBar.vue'
import EditToolRail from '@/components/map-editor/EditToolRail.vue'
import EditPropertyPanel from '@/components/map-editor/EditPropertyPanel.vue'
import EditStatusBar from '@/components/map-editor/EditStatusBar.vue'
import AiGenerateDialog from '@/components/map-editor/AiGenerateDialog.vue'
import ProjectBrowseDialog from '@/components/map-editor/ProjectBrowseDialog.vue'
import { useAuthStore } from '@/stores/auth'
import { useEditTool, type EditToolId } from '@/composables/useEditTool'
import { useSelection, type SelectionItem } from '@/composables/useSelection'
import { useHistory } from '@/composables/useHistory'
import { useCanvasBridge } from '@/composables/useCanvasBridge'
import { useMapEditStream } from '@/composables/useMapEditStream'
import { buildAlignOps, type AlignTarget, type AlignMode } from '@/composables/useAlignment'
import { genLinearH, genLinearV, genMatrix, genCircular } from '@/composables/useBatchGenerate'
import { mapEditApi, aiConfigApi } from '@/api/mapEdit'
import { reflectionApi, type ReflectionMethod, type ReflectionObject, type ReflectionKind } from '@/api/reflection'

const auth = useAuthStore()
const vrHost = (import.meta.env.VITE_VRENDER_HOST as string | undefined) ?? 'localhost:8223'

const workspaceRef = ref<InstanceType<typeof Workspace3D> | null>(null)
const fileInputRef = ref<HTMLInputElement | null>(null)
const fileAcceptHint = ref<string>('image/*')
const fileUploadKind = ref<'image' | 'model'>('image')

const editTool = useEditTool()
const selection = useSelection()
const history = useHistory({})

const mouseXY = reactive({ x: 0, y: 0 })

const aiDialogOpen = ref(false)
const aiConfigured = ref(false)
const defaultAiBounds = ref<[number, number, number, number]>([-10000, -10000, 10000, 10000])

const projectBrowseOpen = ref(false)
const projectBrowseMode = ref<'load' | 'save'>('load')
const projectBrowseInitial = ref<string>('')

const saving = ref(false)

const propertyLoading = ref(false)
const primary = ref<ReflectionObject | null>(null)
const primaryFields = ref<Array<{ key: string; value: string }>>([])
const primaryMethods = ref<ReflectionMethod[]>([])
const primaryStatus = ref<Array<{ key: string; value: string }>>([])

// 默认图层与 SimpleLite/Prop.layerName 默认值一致（'g'）；
// 顶栏「图层」菜单提供「新建图层 / 切换显示图层」入口，新建后会自动设为
// defaults.site.layer / defaults.track.layer，下一次 Add-Site / Add-Track 自动落到新层。
const defaults = reactive({
  site: { layer: 'g', namePrefix: 'S', color: '#ffffff' },
  track: { layer: 'g', lineWidth: 2, color: '#88ccff' }
})

const knownLayers = ref<string[]>(['g'])
interface LayerRow { name: string; visible: boolean; selectable: boolean; color: string }
const layerRows = ref<LayerRow[]>([
  { name: 'g', visible: true, selectable: true, color: '#cccccc' }
])

// 「过滤」菜单状态：对齐 SimpleLite 桌面端 WorkspaceBottomBar 的「选择 / 对齐 / 显示内容」三组开关。
// 三组语义：
//   selectFilter.*  画布命中后允许进入 selection 的对象类型，与 SimpleUI.SelectFilter{Sites,Tracks,Cars,Decor} 对齐。
//   alignSnap.*     绘制工具吸附时考虑的目标类型，与 SimpleUI.AlignSnap{Sites,Cars,Tracks} 对齐。
//   showViewport.*  显示内容过滤，与 SimpleUI.ShowViewport{SceneLabels,ScenePrimitives,Cars} 对齐。
//
// 当前阶段仅在 Vue 前端生效（影响 selection 接收 / 属性面板筛选）；画布本身的可见性由 SimpleLite
// 端 LayerToShow / 各 ShowViewport* 控制，待后端 REST API 打通后再做双向同步。
const viewFilter = reactive<ViewFilterState>({
  selectFilter: { sites: true, tracks: true, cars: true, decor: true },
  alignSnap: { sites: true, cars: true, tracks: true },
  showViewport: { sceneLabels: true, scenePrimitives: true, cars: true }
})

function persistViewFilter() {
  try { localStorage.setItem('mapEditor.viewFilter', JSON.stringify(viewFilter)) }
  catch { /* localStorage 可能被禁用，忽略 */ }
}

function loadViewFilter() {
  try {
    const raw = localStorage.getItem('mapEditor.viewFilter')
    if (!raw) return
    const data = JSON.parse(raw) as Partial<ViewFilterState>
    if (data.selectFilter) Object.assign(viewFilter.selectFilter, data.selectFilter)
    if (data.alignSnap) Object.assign(viewFilter.alignSnap, data.alignSnap)
    if (data.showViewport) Object.assign(viewFilter.showViewport, data.showViewport)
  } catch { /* 旧格式或损坏，忽略 */ }
}

/**
 * 当 selectFilter 中的某类型被关闭时，把当前 selection 里这类对象剔掉。
 * 否则之前选中的、现在不该被选的会一直留在右侧属性面板，体验突兀。
 */
function pruneSelectionByFilter() {
  const sf = viewFilter.selectFilter
  const kept = selection.items.value.filter((it) => {
    if (it.kind === 'site') return sf.sites
    if (it.kind === 'track') return sf.tracks
    if (it.kind === 'car') return sf.cars
    if (it.kind === 'special') return sf.decor
    return true
  })
  if (kept.length !== selection.items.value.length) selection.set(kept)
}

// 「切换显示图层」对话框。开关 visible 直接改 layerRows[i]，与右侧 EditPropertyPanel 共享 state。
// 注：画布渲染本身由 SimpleLite 端 LayerToShow 控制，目前后端没有暴露写 API；
// 这里 toggle 仅控制前端面板的可见性筛选（同步到画布需另加后端 API，待 Fody 修好再做）。
const layerVisibilityDialogOpen = ref(false)

// 用于「绘制路径」工具的两次拾取状态机：第一次记录 siteA，第二次创建路径
const drawTrackState = ref<{ siteA: number | null }>({ siteA: null })

// 贝塞尔 / NURBS 创建第二步：siteA + siteB 拾取完后，会进入 N 次「中间控制点」连续拾取阶段。
// 这一阶段直接 await mapEditApi.pick()，与 startPickLoop 那条主循环并存会出现两条
// pick 监听竞争同一条 SSE pick-result。inFollowupPick=true 期间：
//   - startPickLoop 已被 handleTrackPick 主动 break，不会再拉新 pick；
//   - onCanvasPick（iframe → workspace.pick postMessage 旁路）短路返回，避免被
//     再走一次工具状态机重复落对象。
const inFollowupPick = ref(false)

const streamConnected = ref(false)

// 选中同步防回环：保存"本地刚发出去"的选中 key。
// 后端广播 selection-detail 时如果与此 key 等价，跳过 selection.set，避免:
//   watch(selection) → reflectionApi.setSelection → SSE selection-detail → onSelectionDetail
//   → selection.set → 再 watch 的死循环。
const lastSyncedSelectionKey = ref<string>('')

function makeSelectionKey(list: Array<{ kind: string; id: number }>): string {
  return list.map((x) => `${x.kind}:${x.id}`).sort().join(',')
}

// 拾取循环开关：切换到拾取工具时拉起，切走时落下。
const pickLoopActive = ref(false)
const pickToolIds: EditToolId[] = [
  'draw.site',
  'draw.sites.continuous',
  'draw.track.polyline',
  'draw.track.bezier',
  'draw.track.arc',
  'draw.track.nurbs'
]

async function startPickLoop() {
  if (pickLoopActive.value) return
  pickLoopActive.value = true
  // 工具栏交互约定：
  // - 单次工具（draw.site / draw.track.*）：完成一次操作后自动退出循环 + 退选。
  // - 连续工具（draw.sites.continuous）：保持选中状态、循环到右键取消（pick 抛 cancel）为止。
  // - 用户主动切换工具：onSelectTool 把 pickLoopActive 置 false 并 setTool 新工具，
  //   循环退出后我们检测 activeTool 已不是 pick 类，就不覆盖；仅在还停留在 pick 类时
  //   把它切回 transform.move，以避免按钮残留高亮。
  while (pickLoopActive.value) {
    let result: { x: number; y: number; siteId: number } | null = null
    try {
      result = await mapEditApi.pick()
    } catch {
      break
    }
    if (!pickLoopActive.value) break
    const tool = editTool.activeTool.value
    if (!pickToolIds.includes(tool)) break

    if (tool === 'draw.site') {
      await runCreateSite(result.x, result.y)
      ElMessage.success(`已添加站点 (${result.x.toFixed(0)}, ${result.y.toFixed(0)})`)
      break
    } else if (tool === 'draw.sites.continuous') {
      await runCreateSite(result.x, result.y)
      // 连续模式：不 break，等待下一次 pick；右键 → mapEditApi.pick() 抛错 → 走 catch break。
    } else if (tool === 'draw.track.polyline' || tool === 'draw.track.bezier' || tool === 'draw.track.arc' || tool === 'draw.track.nurbs') {
      // handleTrackPick 是两次 pick 的状态机：第一次 siteA: null→x（继续等第二次），
      // 第二次完成后 siteA: x→null（已经创建了 track / 异常 reset）。所以"siteA 之前不为空、
      // 现在为空"就视为完成了一条 track，单次工具退出。
      const before = drawTrackState.value.siteA
      await handleTrackPick(result.siteId)
      if (before != null && drawTrackState.value.siteA == null) {
        const label = tool === 'draw.track.bezier' ? '贝塞尔路径'
          : tool === 'draw.track.arc' ? '弧形路径'
          : tool === 'draw.track.nurbs' ? 'NURBS 路径'
          : '直线路径'
        ElMessage.success(`已添加${label}`)
        break
      }
    } else {
      break
    }
  }
  pickLoopActive.value = false
  // 单次完成 / 右键取消 / 工具切换都退出循环；只有当用户没主动切到别的工具时
  // （activeTool 仍停留在 pick 类工具）才切回 transform.move 取消按钮高亮。
  if (pickToolIds.includes(editTool.activeTool.value)) {
    editTool.setTool('transform.move')
  }
}

const snapAny = computed(() => editTool.snap.value.grid || editTool.snap.value.object || editTool.snap.value.endpoint || editTool.snap.value.midpoint)

// ──────────────────────────────────────────────────────────────────────────
// 顶栏菜单 dispatch
// ──────────────────────────────────────────────────────────────────────────

async function onTopBarCmd(cmd: string) {
  switch (cmd) {
    case 'edit.undo': await history.undo(); break
    case 'edit.redo': await history.redo(); break
    case 'edit.delete': await deleteSelection(); break
    case 'edit.selectAll': ElMessage.info('全选：请使用工具栏「按类型选」批量选中'); break
    case 'edit.invert': ElMessage.info('反选功能后续实现'); break
    case 'project.save': await onProjectSave(false); break
    case 'project.open': await onProjectOpen(); break
    case 'project.saveAs': await onProjectSave(true); break
    case 'project.close': ElMessage.info('关闭项目：使用 SimpleLite 主菜单'); break
    case 'ai.open': await openAiDialog(); break
    case 'file.upload.image': triggerUpload('image'); break
    case 'file.upload.model': triggerUpload('model'); break
    case 'view.fitAll': ElMessage.info('请在 3D 视口右键菜单使用「适配全部」'); break
    case 'view.fitSelection': ElMessage.info('请在 3D 视口右键菜单使用「适配选中」'); break
    case 'snap.toGrid': editTool.toggleSnap('grid'); break
    case 'snap.toObject': editTool.toggleSnap('object'); break
    // 「添加」菜单：用坐标 / ID 直接落地，不依赖画布 GetPoint。
    case 'add.site.byCoord': await promptAddSiteByCoord(); break
    case 'add.track.polyline.byIds': await promptAddTrackByIds('track'); break
    case 'add.track.bezier.byIds': await promptAddTrackByIds('bezier'); break
    case 'add.track.nurbs.byIds': await promptAddTrackByIds('nurbs'); break
    case 'add.track.arc.byIds': await promptAddTrackByIds('arc'); break
    case 'add.car.dummy.byCoord': await promptAddDummyCarByCoord(); break
    // 「图层」菜单：新建图层 / 切换图层显示
    case 'layer.new': await promptCreateLayer(); break
    case 'layer.toggleVisibility': layerVisibilityDialogOpen.value = true; break
    // 「过滤」菜单：选择 / 对齐 / 显示三组开关
    case 'view.select.sites': toggleViewFilter('selectFilter', 'sites'); break
    case 'view.select.tracks': toggleViewFilter('selectFilter', 'tracks'); break
    case 'view.select.cars': toggleViewFilter('selectFilter', 'cars'); break
    case 'view.select.decor': toggleViewFilter('selectFilter', 'decor'); break
    case 'view.snap.sites': toggleViewFilter('alignSnap', 'sites'); break
    case 'view.snap.cars': toggleViewFilter('alignSnap', 'cars'); break
    case 'view.snap.tracks': toggleViewFilter('alignSnap', 'tracks'); break
    case 'view.show.sceneLabels': toggleViewFilter('showViewport', 'sceneLabels'); break
    case 'view.show.scenePrimitives': toggleViewFilter('showViewport', 'scenePrimitives'); break
    case 'view.show.cars': toggleViewFilter('showViewport', 'cars'); break
    default: ElMessage.info(`暂未实现的命令：${cmd}`)
  }
}

/**
 * 切换某一个 viewFilter 开关并落地副作用：
 * - 立即更新本地 reactive 状态，保证菜单勾选和右侧属性面板立即响应；
 * - selectFilter 改变后立即从 selection 里剔除被禁的类型；
 * - 同步调 `POST /map-edit/view-filter` 把变化写到 SimpleLite 后端：
 *     selectFilter → SimpleUI.SelectFilter* + RebuildWorkspaceSelectablePatterns（影响画布拾取 + hover）
 *     alignSnap   → SimpleUI.AlignSnap*（影响绘制工具吸附）
 *     showViewport → SimpleUI.ShowViewport*（影响画布渲染：取消勾选后对应对象消失）
 *   后端会触发 SimpleSceneRenderer.NotifyStateChanged() 立即重画画布。
 * - 三组都会写一次 localStorage 记住偏好。
 *
 * E2 修复：后端请求失败时回滚本地状态 + localStorage，让菜单勾选与"实际生效的过滤"保持一致 ——
 * 否则用户看到菜单是 ON、画布表现是 OFF，会以为 SimpleLite 配置错乱。回滚后再弹一条带"重试"按钮的提示。
 */
async function toggleViewFilter<G extends keyof ViewFilterState>(
  group: G,
  key: keyof ViewFilterState[G]
) {
  const groupObj = viewFilter[group] as Record<string, boolean>
  const k = key as string
  const prev = groupObj[k]
  groupObj[k] = !groupObj[k]
  persistViewFilter()
  if (group === 'selectFilter') pruneSelectionByFilter()

  const patch: Record<string, Record<string, boolean>> = {}
  patch[group as string] = { [k]: groupObj[k] }
  try {
    await mapEditApi.setViewFilter(patch)
  } catch (err) {
    console.warn('[MapEditor] 写后端 viewFilter 失败：', err)
    // 回滚本地状态，与后端保持一致
    groupObj[k] = prev
    persistViewFilter()
    if (group === 'selectFilter') pruneSelectionByFilter()
    ElMessage({
      message: '过滤设置写入后端失败，已回滚本地状态；请检查 SimpleLite 是否连通后重试。',
      type: 'warning',
      duration: 5000,
      showClose: true
    })
  }
}

// ──────────────────────────────────────────────────────────────────────────
// 「添加」菜单：坐标 / ID 直接落地（绕过画布 pick，覆盖 GetPoint 不响应等场景）
// ──────────────────────────────────────────────────────────────────────────

async function promptAddSiteByCoord() {
  try {
    const x = Number((await ElMessageBox.prompt('X 坐标 (mm)', '添加站点', { inputValue: '0' })).value)
    const y = Number((await ElMessageBox.prompt('Y 坐标 (mm)', '添加站点', { inputValue: '0' })).value)
    if (!Number.isFinite(x) || !Number.isFinite(y)) {
      ElMessage.warning('坐标必须是数字')
      return
    }
    await runCreateSite(x, y)
  } catch { /* user cancelled */ }
}

async function promptAddTrackByIds(kind: 'track' | 'bezier' | 'arc' | 'nurbs') {
  const label = trackKindLabel(kind)
  try {
    const a = Math.floor(Number((await ElMessageBox.prompt('起点站 siteA (id)', `添加${label}`, { inputValue: '' })).value))
    const b = Math.floor(Number((await ElMessageBox.prompt('终点站 siteB (id)', `添加${label}`, { inputValue: '' })).value))
    if (!Number.isInteger(a) || !Number.isInteger(b) || a <= 0 || b <= 0) {
      ElMessage.warning('siteA / siteB 必须是正整数')
      return
    }
    if (a === b) { ElMessage.warning('起止站点不能相同'); return }
    drawTrackState.value.siteA = null

    // 收集 typeInfo（贝塞尔 / NURBS：连续输入控制点坐标；弧线：输入半径自动算圆心）。
    // 失败 / 取消 → typeInfo = null，下面分两种情况：
    //   - 用户主动取消 → 直接 return（不创建 track）
    //   - 取不到 site 坐标兜底 → 仍然创建 track 但不带 typeInfo（与旧行为一致）
    let typeInfo: string | undefined
    let extraLabel = ''
    if (kind === 'bezier' || kind === 'nurbs') {
      const r = await collectCurveTypeInfoByCoord(a, b, kind)
      if (r === 'cancelled') return
      if (r != null) {
        typeInfo = r.typeInfo
        extraLabel = `（控制点 ${r.midCount}）`
      }
    } else if (kind === 'arc') {
      const r = await collectArcTypeInfoByCoord(a, b)
      if (r === 'cancelled') return
      if (r != null) {
        typeInfo = r.typeInfo
        extraLabel = `（半径 ${r.radius.toFixed(0)}）`
      }
    }

    let lastId = -1
    await history.run({
      label: `新建${label} (${a}→${b})${extraLabel}`,
      apply: async () => {
        const payload: Record<string, unknown> = { siteA: a, siteB: b, layer: defaults.track.layer }
        if (typeInfo) payload.typeInfo = typeInfo
        const r = await mapEditApi.createTrack(kind, payload as { siteA: number; siteB: number })
        lastId = r.id
      },
      revert: async () => {
        if (lastId > 0) await mapEditApi.deleteObject('track', lastId)
      }
    })
    notifyTrackCreatedDefaults(kind)
  } catch { /* user cancelled */ }
}

function trackKindLabel(kind: 'track' | 'bezier' | 'arc' | 'nurbs'): string {
  return kind === 'bezier' ? '贝塞尔路径'
    : kind === 'nurbs' ? 'NURBS 路径'
    : kind === 'arc' ? '弧形路径'
    : '直线路径'
}

/**
 * 在「按 ID 添加」场景下收集贝塞尔 / NURBS 的中间控制点坐标，并拼出 typeInfo 字符串。
 *
 * 返回值：
 *   - { typeInfo, midCount }：成功
 *   - null：拉不到 site 坐标，调用方仍可创建 track 但不带 typeInfo
 *   - 'cancelled'：用户在某个对话框点了取消，调用方应直接 return（不创建 track）
 */
async function collectCurveTypeInfoByCoord(
  a: number,
  b: number,
  kind: 'bezier' | 'nurbs'
): Promise<{ typeInfo: string; midCount: number } | null | 'cancelled'> {
  const sa = await fetchSiteXY(a)
  const sb = await fetchSiteXY(b)
  if (!sa || !sb) {
    ElMessage.warning(`无法拉到 site #${a} / #${b} 的 x,y，将不带 typeInfo 创建 track（控制点会用兜底直线）`)
    return null
  }
  const label = trackKindLabel(kind)
  let midCount: number
  try {
    const v = await ElMessageBox.prompt(
      '请输入中间控制点个数（不含起止站点；默认 2）',
      `添加${label}`,
      { inputValue: '2', inputPattern: /^\d+$/, inputErrorMessage: '请输入非负整数' }
    )
    midCount = Math.max(0, Math.floor(Number(v.value)))
  } catch { return 'cancelled' }

  const midPts: XYPoint[] = []
  for (let i = 0; i < midCount; i++) {
    let xv: number
    let yv: number
    try {
      const vx = await ElMessageBox.prompt(
        `第 ${i + 1}/${midCount} 个控制点 X (mm)`,
        `添加${label}`,
        { inputValue: String(((sa.x + sb.x) / 2).toFixed(0)) }
      )
      xv = Number(vx.value)
      const vy = await ElMessageBox.prompt(
        `第 ${i + 1}/${midCount} 个控制点 Y (mm)`,
        `添加${label}`,
        { inputValue: String(((sa.y + sb.y) / 2).toFixed(0)) }
      )
      yv = Number(vy.value)
    } catch { return 'cancelled' }
    if (!Number.isFinite(xv) || !Number.isFinite(yv)) {
      ElMessage.warning('控制点坐标必须是数字')
      return 'cancelled'
    }
    midPts.push({ x: xv, y: yv })
  }
  const allPts: XYPoint[] = [sa, ...midPts, sb]
  return {
    typeInfo: buildCurveTypeInfo(allPts, kind === 'bezier' ? 2 : 3),
    midCount
  }
}

/**
 * 「按 ID 添加」场景下收集弧线半径并拼出 typeInfo。
 *
 * 半径校验：必须 ≥ |AB|/2（小于则圆心不存在或重合，无法形成弧）。默认值用 ceil(|AB|/2) 即半圆。
 */
async function collectArcTypeInfoByCoord(
  a: number,
  b: number
): Promise<{ typeInfo: string; radius: number } | null | 'cancelled'> {
  const sa = await fetchSiteXY(a)
  const sb = await fetchSiteXY(b)
  if (!sa || !sb) {
    ElMessage.warning(`无法拉到 site #${a} / #${b} 的 x,y，将不带 typeInfo 创建 track（圆心需手动设置）`)
    return null
  }
  const ab = Math.hypot(sa.x - sb.x, sa.y - sb.y)
  const minR = ab / 2
  let radius: number
  try {
    const v = await ElMessageBox.prompt(
      `请输入半径 (mm)，必须 ≥ ${minR.toFixed(1)}（等于即半圆）`,
      '添加弧形路径',
      { inputValue: String(Math.ceil(minR)) }
    )
    radius = Number(v.value)
  } catch { return 'cancelled' }
  if (!Number.isFinite(radius) || radius < minR) {
    ElMessage.warning(`半径必须 ≥ ${minR.toFixed(1)} mm`)
    return 'cancelled'
  }
  return {
    typeInfo: buildArcTypeInfo(sa, sb, radius),
    radius
  }
}

/**
 * 创建完一条 bezier / arc / nurbs 路径后给用户一句简短反馈。
 * 现在所有几何参数（贝塞尔/NURBS 的控制点、弧线的圆心+半径）都在创建前就由前端
 * 通过 typeInfo 字段一并落盘，画布选中时 SimpleSceneRenderer.DrawTrackEditHandles
 * 能直接画出控制点 / 控制折线 / 圆心，无需用户再走属性面板「设置圆心」「输入控制点」补救。
 */
function notifyTrackCreatedDefaults(kind: 'track' | 'bezier' | 'arc' | 'nurbs') {
  if (kind === 'bezier' || kind === 'nurbs') {
    ElMessage.success(
      `${kind === 'bezier' ? '贝塞尔' : 'NURBS'}路径已创建。选中后可在画布看到控制点（白色端点 + 黄色中间点）和控制折线，可在属性面板拖动 / 输入坐标 / 增删控制点继续调整。`
    )
  } else if (kind === 'arc') {
    ElMessage.success(
      '弧形路径已创建。选中后可在画布看到圆心；属性面板「设置圆心、半径」「设定相邻切线」可继续微调。'
    )
  }
}

// ──────────────────────────────────────────────────────────────────────────
// typeInfo 几何工具：把贝塞尔 / NURBS / 弧线的几何序列化为 SimpleLite 后端
// `Track.typeInfo`（[FieldMember]）可识别的字符串。后端 ApplyExtraFields 会通过
// reflection 把 typeInfo 写到刚刚 new 出的 UIBezierTrack/UICircularArcTrack/UINurbsTrack
// 上，画布渲染时 TrackTypeInfoGeometry.HydrateTypedTrackFields / TrySamplePolyline
// 即按此字符串还原 BezierCurve.ControlPoints / Arc 几何，从而：
//   - 第一次渲染就显示正确曲线（不是 EnsureControlPoints 的 [A, midpoint, B] 兜底直线）
//   - 选中后 DrawTrackEditHandles 立刻画出控制点 + 灰色控制折线 + 圆心十字
//
// 格式（来自 TrackTypeInfoGeometry & SimpleComposer 端 UpdateInfo）：
//   弧线   : "1,{cx},{cy},{radius},{angleStartDeg},{angleEndDeg}"
//   贝塞尔 : "2,{count},{x0},{y0},{x1},{y1},...,{x_{count-1}},{y_{count-1}}"
//   NURBS  : "3,{count},{x0},{y0},...,{x_{count-1}},{y_{count-1}}"
// 坐标都是毫米，与 Site.x/y 量纲一致。
// ──────────────────────────────────────────────────────────────────────────

interface XYPoint { x: number; y: number }

/** 拉取站点真实坐标用于拼 typeInfo（后端 createTrack 不会回填 site 坐标，前端必须自己取）。 */
async function fetchSiteXY(id: number): Promise<XYPoint | null> {
  try {
    const b = await reflectionApi.getBundle('site', id)
    const x = Number(b.fields?.x ?? NaN)
    const y = Number(b.fields?.y ?? NaN)
    if (!Number.isFinite(x) || !Number.isFinite(y)) return null
    return { x, y }
  } catch { return null }
}

/** 贝塞尔 / NURBS 共享控制折线序列化（kindFlag=2 贝塞尔 / 3 NURBS）。allPoints = [siteA, ...中间, siteB]。 */
function buildCurveTypeInfo(allPoints: XYPoint[], kindFlag: 2 | 3): string {
  const parts: string[] = [String(kindFlag), String(allPoints.length)]
  for (const p of allPoints) {
    parts.push(p.x.toFixed(3))
    parts.push(p.y.toFixed(3))
  }
  return parts.join(',')
}

/**
 * 弧线 typeInfo：圆心在 AB 中垂线上、距 AB 中点 d = sqrt(R² - |AB|²/4)。
 * 调用前应已校验 radius >= |AB|/2。side='left' 时圆心在 AB 法线左侧（默认）。
 */
function buildArcTypeInfo(sa: XYPoint, sb: XYPoint, radius: number, side: 'left' | 'right' = 'left'): string {
  const ax = sb.x - sa.x
  const ay = sb.y - sa.y
  const len = Math.hypot(ax, ay)
  const halfAb = len / 2
  const dSq = radius * radius - halfAb * halfAb
  const d = Math.sqrt(Math.max(0, dSq))
  const px = len > 0 ? -ay / len : 0
  const py = len > 0 ? ax / len : 0
  const sign = side === 'left' ? 1 : -1
  const cx = (sa.x + sb.x) / 2 + sign * d * px
  const cy = (sa.y + sb.y) / 2 + sign * d * py
  const angleStart = Math.atan2(sa.y - cy, sa.x - cx) * 180 / Math.PI
  const angleEnd = Math.atan2(sb.y - cy, sb.x - cx) * 180 / Math.PI
  return ['1', cx.toFixed(3), cy.toFixed(3), radius.toFixed(3), angleStart.toFixed(3), angleEnd.toFixed(3)].join(',')
}

// 把指定图层设为下次创建对象的默认落点。也作为 dialog 中「设为默认」按钮回调。
function setDefaultLayer(name: string) {
  if (!name) return
  defaults.site.layer = name
  defaults.track.layer = name
  ElMessage.success(`默认图层已切到 "${name}"`)
}

// 「图层」菜单：弹出 prompt 输入新图层名，加进 knownLayers/layerRows 并把它设为
// defaults.site.layer / defaults.track.layer。下次 Add-Site / Add-Track 自动落到新层。
async function promptCreateLayer() {
  try {
    const v = await ElMessageBox.prompt('请输入新图层名（同名会被忽略）', '新建图层', { inputValue: '' })
    const name = (v.value ?? '').trim()
    if (!name) return
    if (knownLayers.value.includes(name)) {
      // 同名直接切到该层作为当前默认层即可
      defaults.site.layer = name
      defaults.track.layer = name
      ElMessage.info(`图层 "${name}" 已存在，已切到该层`)
      return
    }
    onAddLayer(name)
    defaults.site.layer = name
    defaults.track.layer = name
    ElMessage.success(`已创建图层 "${name}"，下次创建对象会落到该层`)
  } catch { /* user cancelled */ }
}

async function promptAddDummyCarByCoord() {
  try {
    const x = Number((await ElMessageBox.prompt('X 坐标 (mm)', '添加模拟车', { inputValue: '0' })).value)
    const y = Number((await ElMessageBox.prompt('Y 坐标 (mm)', '添加模拟车', { inputValue: '0' })).value)
    const theta = Number((await ElMessageBox.prompt('朝向 theta (rad，0 = +X 向右)', '添加模拟车', { inputValue: '0' })).value)
    const name = (await ElMessageBox.prompt('车名（可空）', '添加模拟车', { inputValue: '' })).value
    if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(theta)) {
      ElMessage.warning('坐标 / 朝向必须是数字')
      return
    }
    let createdId = -1
    await history.run({
      label: `新建模拟车 (${x.toFixed(0)}, ${y.toFixed(0)})`,
      apply: async () => {
        const r = await mapEditApi.createCar({ x, y, theta, name })
        createdId = r.id
      },
      revert: async () => {
        if (createdId > 0) await mapEditApi.deleteObject('car', createdId)
      }
    })
  } catch { /* user cancelled */ }
}

// ──────────────────────────────────────────────────────────────────────────
// 工具栏交互
// ──────────────────────────────────────────────────────────────────────────

async function onSelectTool(id: EditToolId) {
  editTool.setTool(id)
  drawTrackState.value.siteA = null

  // 切换工具会终止上一轮拾取循环（SimpleLite 端的 GetPoint 会被下一次 cancel 截断）
  pickLoopActive.value = false

  // 一些工具是一次性 action，点完直接执行：
  if (id === 'select.clear') {
    selection.clear()
    await reflectionApi.clearSelection().catch(() => {})
    return
  }
  if (pickToolIds.includes(id)) {
    // 切到需要画布拾取的工具：异步起一次 pick 循环；用户每点一次就反射调 create。
    startPickLoop()
  }
  if (id.startsWith('align.')) {
    await applyAlignment(id.replace('align.', '') as AlignMode)
    return
  }
  if (id.startsWith('batch.')) {
    await runBatchGenerate(id)
    return
  }
  if (id === 'transform.delete') {
    await deleteSelection()
    return
  }
  if (id.startsWith('select.byType.')) {
    const t = id.replace('select.byType.', '')
    applyTypeFilter(t)
    return
  }
  if (id === 'show.controlPoints') editTool.showControlPoints.value = !editTool.showControlPoints.value
  if (id === 'show.controlHandles') editTool.showControlHandles.value = !editTool.showControlHandles.value
  if (id === 'draw.image') triggerUpload('image')
  if (id === 'draw.model') triggerUpload('model')
  if (id === 'transform.copyFields') { await runCopyFields(); return }
}

function applyTypeFilter(t: string) {
  if (t === 'site') selection.filterByKind('site')
  else if (t === 'polyline' || t === 'bezier' || t === 'arc') {
    selection.items.value = selection.items.value.filter((s) => {
      if (s.kind !== 'track') return false
      const tn = s.typeName.toLowerCase()
      if (t === 'polyline') return tn === 'uitrack'
      if (t === 'bezier') return tn.includes('bezier')
      if (t === 'arc') return tn.includes('arc')
      return false
    })
  } else if (t === 'decor') selection.filterByKind('special')
  else if (t === 'currentLayer' && primary.value?.layer) {
    selection.items.value = selection.items.value.filter((s) => s.layer === primary.value!.layer)
  }
}

// ──────────────────────────────────────────────────────────────────────────
// 画布事件：根据当前工具决定 pick 后的行为
// ──────────────────────────────────────────────────────────────────────────

async function onCanvasPick(p: { x: number; y: number }) {
  mouseXY.x = p.x
  mouseXY.y = p.y

  const id = editTool.activeTool.value

  // 双路径防护：pick 类工具激活后，所有创建/连线动作由 startPickLoop 一家负责
  // （它会等待后端 SSE pick-result）。useCanvasBridge.onPick 收到 iframe 的
  // workspace.pick postMessage 时如果再触发一次 runCreateSite/handleTrackPick，
  // 同一次点击会被创建两个站点。
  if (pickLoopActive.value && pickToolIds.includes(id)) return

  // 贝塞尔 / NURBS 收集中间控制点期间，handleTrackPick 已直接 await mapEditApi.pick()。
  // 这里再走一次状态机会把同一次点击当成新一轮的 siteA。短路即可，pick-result 已被
  // collectAndCreateBezierLike 消费。
  if (inFollowupPick.value) return

  if (id === 'draw.site' || id === 'draw.sites.continuous') {
    await runCreateSite(p.x, p.y)
    return
  }
  if (id === 'draw.track.polyline' || id === 'draw.track.bezier' || id === 'draw.track.arc' || id === 'draw.track.nurbs') {
    try {
      const pick = await mapEditApi.pick()
      await handleTrackPick(pick.siteId)
    } catch { /* user cancelled */ }
    return
  }
}

function onCanvasSelect(names: string[]) {
  // names 形如 ["UISite-12", "UITrack-7", "UIBezierTrack-3", "UICircularArcTrack-5", "Car-2", ...]
  // 直接走 selection.set，由 parseSelectionNames 在内部按 viewFilter.selectFilter 过滤掉被禁类型。
  selection.set(parseSelectionNames(names))
}

/**
 * 把 SimpleLite 端 prop name 解析为 SelectionItem。
 * 之前的 onCanvasSelect 用了 "(?:Track)?" 的非贪婪正则，会把
 * "UIBezierTrack-3" 拆成 typeName="Bezier" → kind 错判成 "special"。
 * 这里改成统一逻辑：贪婪吃完字母前缀，再用 typeName 内容判定 kind。
 *
 * 解析后会按 viewFilter.selectFilter 过滤——和 SimpleLite 桌面端「选择」菜单语义一致：
 * 关闭某类型则该类型不会被选中（即使画布层面物理上点中了它）。
 */
function parseSelectionNames(names: readonly string[]): SelectionItem[] {
  const items: SelectionItem[] = []
  const sf = viewFilter.selectFilter
  for (const n of names) {
    const m = /^UI([A-Za-z]+)-(\d+)$/.exec(n) ?? /^([A-Za-z]+)-(\d+)$/.exec(n)
    if (!m) continue
    const rawType = m[1]!
    const id = Number(m[2])
    const tn = rawType.toLowerCase()
    let kind: ReflectionKind
    if (tn === 'site') kind = 'site'
    else if (tn === 'car' || tn === 'vehicle') kind = 'car'
    else if (tn.includes('track')) kind = 'track'
    else kind = 'special'

    if (kind === 'site' && !sf.sites) continue
    if (kind === 'track' && !sf.tracks) continue
    if (kind === 'car' && !sf.cars) continue
    if (kind === 'special' && !sf.decor) continue

    items.push({
      kind,
      id,
      name: n,
      typeName: n.startsWith('UI') ? `UI${rawType}` : rawType
    })
  }
  return items
}

async function handleTrackPick(siteId: number) {
  if (siteId <= 0) {
    ElMessage.warning('请先拾取一个站点')
    return
  }
  if (drawTrackState.value.siteA == null) {
    drawTrackState.value.siteA = siteId
    ElMessage.success(`已选起点站 #${siteId}，请再拾取终点站`)
    return
  }
  const a = drawTrackState.value.siteA
  const b = siteId
  drawTrackState.value.siteA = null
  if (a === b) { ElMessage.warning('起止站点不能相同'); return }

  const kind: 'track' | 'bezier' | 'arc' | 'nurbs' =
    editTool.activeTool.value === 'draw.track.bezier' ? 'bezier'
    : editTool.activeTool.value === 'draw.track.arc' ? 'arc'
    : editTool.activeTool.value === 'draw.track.nurbs' ? 'nurbs'
    : 'track'

  // 收集 typeInfo（贝塞尔/NURBS：弹个数后在画布上连续 pick；弧线：弹半径自动算圆心）。
  // 用户取消或参数非法 → 整个创建动作终止，不调 createTrack（避免落一条没有几何的曲线）。
  let typeInfo: string | undefined
  let extraLabel = ''
  if (kind === 'bezier' || kind === 'nurbs') {
    const r = await collectCurveTypeInfoByPick(a, b, kind)
    if (r === 'cancelled') return
    if (r != null) {
      typeInfo = r.typeInfo
      extraLabel = `（控制点 ${r.midCount}）`
    }
  } else if (kind === 'arc') {
    const r = await collectArcTypeInfoByCoord(a, b)
    if (r === 'cancelled') return
    if (r != null) {
      typeInfo = r.typeInfo
      extraLabel = `（半径 ${r.radius.toFixed(0)}）`
    }
  }

  let lastId = -1
  await history.run({
    label: `新建${trackKindLabel(kind)} (${a}→${b})${extraLabel}`,
    apply: async () => {
      const payload: Record<string, unknown> = { siteA: a, siteB: b, layer: defaults.track.layer }
      if (typeInfo) payload.typeInfo = typeInfo
      const r = await mapEditApi.createTrack(kind, payload as { siteA: number; siteB: number })
      lastId = r.id
    },
    revert: async () => {
      if (lastId > 0) await mapEditApi.deleteObject('track', lastId)
    }
  })
  notifyTrackCreatedDefaults(kind)
}

/**
 * 「画布拾取」场景下的贝塞尔 / NURBS typeInfo 收集：
 * 1. 弹「中间控制点个数」对话框（默认 2）。
 * 2. 进入 inFollowupPick=true，连续 N 次 await mapEditApi.pick()——画布任意位置点击都算（不限站点）。
 *    用户右键中途取消 → mapEditApi.pick() 抛错 → 整个收集中止。
 * 3. 收集完拼 typeInfo。
 *
 * 注意：startPickLoop 主循环在 await handleTrackPick 时不会再调 mapEditApi.pick()，
 * 所以这里的 pick() 不会与主循环竞争同一个 SSE pick-result。
 */
async function collectCurveTypeInfoByPick(
  a: number,
  b: number,
  kind: 'bezier' | 'nurbs'
): Promise<{ typeInfo: string; midCount: number } | null | 'cancelled'> {
  const sa = await fetchSiteXY(a)
  const sb = await fetchSiteXY(b)
  if (!sa || !sb) {
    ElMessage.warning(`无法拉到 site #${a} / #${b} 的 x,y，将不带 typeInfo 创建 track（控制点会用兜底直线）`)
    return null
  }
  const label = trackKindLabel(kind)
  let midCount: number
  try {
    const v = await ElMessageBox.prompt(
      '请输入中间控制点个数（不含起止站点；默认 2，输入 0 则不带控制点直接连接两站点）',
      `创建${label}`,
      { inputValue: '2', inputPattern: /^\d+$/, inputErrorMessage: '请输入非负整数' }
    )
    midCount = Math.max(0, Math.floor(Number(v.value)))
  } catch { return 'cancelled' }

  if (midCount === 0) {
    return { typeInfo: buildCurveTypeInfo([sa, sb], kind === 'bezier' ? 2 : 3), midCount: 0 }
  }

  ElMessage.info(`请在画布上依次点选 ${midCount} 个中间控制点（任意位置；右键取消创建）`)
  inFollowupPick.value = true
  const midPts: XYPoint[] = []
  try {
    for (let i = 0; i < midCount; i++) {
      let r: { x: number; y: number; siteId: number }
      try {
        r = await mapEditApi.pick()
      } catch {
        ElMessage.info('已取消创建')
        return 'cancelled'
      }
      midPts.push({ x: r.x, y: r.y })
      ElMessage.success(`已记录第 ${i + 1}/${midCount} 个控制点 (${r.x.toFixed(0)}, ${r.y.toFixed(0)})`)
    }
  } finally {
    inFollowupPick.value = false
  }
  return {
    typeInfo: buildCurveTypeInfo([sa, ...midPts, sb], kind === 'bezier' ? 2 : 3),
    midCount
  }
}

// ──────────────────────────────────────────────────────────────────────────
// 命令工厂
// ──────────────────────────────────────────────────────────────────────────

async function runCreateSite(x: number, y: number) {
  let createdId = -1
  await history.run({
    label: `新建站点 (${x.toFixed(0)}, ${y.toFixed(0)})`,
    apply: async () => {
      const r = await mapEditApi.createSite({ x, y, layer: defaults.site.layer, name: `${defaults.site.namePrefix}-${Date.now() % 100000}` })
      createdId = r.id
    },
    revert: async () => {
      if (createdId > 0) await mapEditApi.deleteObject('site', createdId)
    }
  })
}

async function deleteSelection() {
  const items = [...selection.items.value]
  if (items.length === 0) return

  // 先抓快照（每个对象的字段全量）用于 revert 时通过 batch.create 重建。
  // 注意：重建后 id 会变；这是后端 SimpleLib.SetSite/SetTrack 自增 id 决定的，
  // 业务上可接受 —— 撤销主要恢复"形态"，id 重排由 SSE 通知所有面板刷新。
  type Snapshot = { kind: string; typeName: string; data: Record<string, unknown> }
  const snapshots: Snapshot[] = []
  for (const it of items) {
    try {
      const b = await reflectionApi.getBundle(it.kind, it.id)
      snapshots.push({ kind: it.kind, typeName: b.typeName, data: { ...(b.fields ?? {}) } })
    } catch { /* 抓不到就跳过 */ }
  }

  await history.run({
    label: `删除 ${items.length} 个对象`,
    apply: async () => {
      const ops = items.map((it) => ({ action: 'delete' as const, kind: it.kind, id: it.id }))
      await mapEditApi.batch(ops)
      selection.clear()
    },
    revert: async () => {
      const ops = snapshots.map((s) => {
        const kindForCreate = pickCreateKind(s.kind, s.typeName)
        return { action: 'create' as const, kind: kindForCreate, data: s.data }
      })
      if (ops.length) {
        await mapEditApi.batch(ops)
        ElMessage.success(`已恢复 ${ops.length} 个对象（注意：id 已重排）`)
      }
    }
  })
}

function pickCreateKind(originalKind: string, typeName: string): string {
  const tn = (typeName ?? '').toLowerCase()
  if (originalKind === 'track') {
    if (tn.includes('bezier')) return 'bezier'
    if (tn.includes('arc')) return 'arc'
    if (tn.includes('nurbs')) return 'nurbs'
    return 'track'
  }
  if (originalKind === 'special') {
    if (tn.includes('image')) return 'image'
    if (tn.includes('text')) return 'text'
    if (tn.includes('model')) return 'model'
  }
  return originalKind
}

async function applyAlignment(mode: AlignMode) {
  // 选中对象必须是有 x,y 坐标的（site / special）
  const targets: AlignTarget[] = []
  for (const it of selection.items.value) {
    if (it.kind === 'track') continue
    // 拉一遍 bundle 拿 x/y
    try {
      const b = await reflectionApi.getBundle(it.kind, it.id)
      const x = Number(b.fields?.x ?? 0)
      const y = Number(b.fields?.y ?? 0)
      targets.push({ kind: it.kind, id: it.id, x, y })
    } catch { /* skip */ }
  }
  if (targets.length < 2) {
    ElMessage.warning('对齐需要选中至少 2 个对象')
    return
  }
  const ops = buildAlignOps(targets, mode)
  if (ops.length === 0) { ElMessage.info('对齐无变化'); return }

  await history.run({
    label: `对齐 (${mode})`,
    apply: async () => { await mapEditApi.batch(ops) },
    revert: async () => {
      // 反向恢复每个对象的原坐标
      const revertOps = targets.map((t) => ({ action: 'patch' as const, kind: t.kind, id: t.id, data: { x: t.x, y: t.y } }))
      await mapEditApi.batch(revertOps)
    }
  })
}

async function runBatchGenerate(id: EditToolId) {
  if (id === 'batch.linearH' || id === 'batch.linearV') {
    const dir = id === 'batch.linearH' ? '横向' : '纵向'
    try {
      const { value } = await ElMessageBox.prompt(`输入 ${dir}站点数量`, '批量生成', { inputValue: '5', inputType: 'number' })
      const count = Math.max(2, Math.floor(Number(value)))
      const a = await ElMessageBox.prompt('起点 X', '批量生成', { inputValue: '0' })
      const b = await ElMessageBox.prompt('起点 Y', '批量生成', { inputValue: '0' })
      const e = await ElMessageBox.prompt('终点 X', '批量生成', { inputValue: '5000' })
      const f = await ElMessageBox.prompt('终点 Y', '批量生成', { inputValue: '0' })
      const ops = id === 'batch.linearH'
        ? genLinearH(Number(a.value), Number(e.value), Number(b.value), { count, layer: defaults.site.layer })
        : genLinearV(Number(a.value), Number(b.value), Number(f.value), { count, layer: defaults.site.layer })
      await applyBatchAsCommand(`${dir}间隔生成 ${count}`, ops)
    } catch { /* cancel */ }
    return
  }
  if (id === 'batch.matrix') {
    try {
      const rows = Number((await ElMessageBox.prompt('行数', '矩阵生成', { inputValue: '3' })).value)
      const cols = Number((await ElMessageBox.prompt('列数', '矩阵生成', { inputValue: '3' })).value)
      const x1 = Number((await ElMessageBox.prompt('x1', '矩阵生成', { inputValue: '0' })).value)
      const y1 = Number((await ElMessageBox.prompt('y1', '矩阵生成', { inputValue: '0' })).value)
      const x2 = Number((await ElMessageBox.prompt('x2', '矩阵生成', { inputValue: '5000' })).value)
      const y2 = Number((await ElMessageBox.prompt('y2', '矩阵生成', { inputValue: '5000' })).value)
      const ops = genMatrix(x1, y1, x2, y2, { rows, cols, layer: defaults.site.layer })
      await applyBatchAsCommand(`矩阵生成 ${rows}×${cols}`, ops)
    } catch { /* cancel */ }
    return
  }
  if (id === 'batch.circular') {
    try {
      const cx = Number((await ElMessageBox.prompt('圆心 X', '环形阵列', { inputValue: '0' })).value)
      const cy = Number((await ElMessageBox.prompt('圆心 Y', '环形阵列', { inputValue: '0' })).value)
      const r = Number((await ElMessageBox.prompt('半径 (mm)', '环形阵列', { inputValue: '2000' })).value)
      const n = Number((await ElMessageBox.prompt('站点数量', '环形阵列', { inputValue: '8' })).value)
      const ops = genCircular(cx, cy, r, { count: n, layer: defaults.site.layer })
      await applyBatchAsCommand(`环形阵列 r=${r} n=${n}`, ops)
    } catch { /* cancel */ }
    return
  }
  if (id === 'batch.alongPath') {
    await runAlongPathSampling()
    return
  }
}

async function runAlongPathSampling() {
  const tracks = selection.items.value.filter((s) => s.kind === 'track')
  if (tracks.length === 0) {
    ElMessage.warning('请先选中至少 1 条路径')
    return
  }
  let spacing = 500
  let includeEndpoints = false
  try {
    const { value } = await ElMessageBox.prompt(`输入采样间距 (mm)`, '沿路径采样', { inputValue: '500', inputType: 'number' })
    spacing = Math.max(10, Math.floor(Number(value)))
    const { value: ep } = await ElMessageBox.prompt('是否包含端点？输入 1 包含，0 不包含', '沿路径采样', { inputValue: '0' })
    includeEndpoints = Number(ep) === 1
  } catch { return }

  const createdAll: number[] = []
  await history.run({
    label: `沿 ${tracks.length} 条路径间距 ${spacing}mm 采样`,
    apply: async () => {
      for (const t of tracks) {
        const r = await mapEditApi.sampleSitesAlongTrack(t.id, { spacingMm: spacing, includeEndpoints, layer: defaults.site.layer })
        createdAll.push(...r.ids)
      }
      ElMessage.success(`已采样生成 ${createdAll.length} 个站点`)
    },
    revert: async () => {
      if (!createdAll.length) return
      const ops = createdAll.map((id) => ({ action: 'delete' as const, kind: 'site', id }))
      await mapEditApi.batch(ops)
    }
  })
}

async function runCopyFields() {
  if (selection.items.value.length < 2) {
    ElMessage.warning('字段复制需要 ≥ 2 个选中对象（第 1 个为源，其余为目标）')
    return
  }
  const [src, ...targets] = selection.items.value
  try {
    const { value: fieldsStr } = await ElMessageBox.prompt(
      '输入要复制的字段名（逗号分隔），例如：x,y,layerName',
      '字段复制',
      { inputValue: 'x,y' }
    )
    const fieldNames = fieldsStr.split(',').map((s) => s.trim()).filter(Boolean)
    if (!fieldNames.length) return

    // 抓 targets 原值用于 revert
    const beforeMap = new Map<string, Record<string, string>>()
    for (const t of targets) {
      try {
        const b = await reflectionApi.getBundle(t.kind, t.id)
        const sub: Record<string, string> = {}
        for (const f of fieldNames) sub[f] = String(b.fields?.[f] ?? '')
        beforeMap.set(`${t.kind}:${t.id}`, sub)
      } catch { /* skip */ }
    }

    await history.run({
      label: `字段复制 ${fieldNames.join(',')} → ${targets.length} 个`,
      apply: async () => {
        const r = await mapEditApi.copyFieldsTo(
          src!.kind,
          src!.id,
          fieldNames,
          targets.map((t) => ({ kind: t.kind, id: t.id }))
        )
        ElMessage.success(`已复制 ${r.copied} 项字段`)
      },
      revert: async () => {
        const ops = targets.flatMap((t) => {
          const before = beforeMap.get(`${t.kind}:${t.id}`)
          if (!before) return []
          return [{ action: 'patch' as const, kind: t.kind, id: t.id, data: before }]
        })
        if (ops.length) await mapEditApi.batch(ops)
      }
    })
  } catch { /* cancel */ }
}

async function applyBatchAsCommand(label: string, ops: { action: 'create' | 'delete' | 'patch'; kind?: string; id?: number; data?: Record<string, unknown> }[]) {
  if (ops.length === 0) return
  let createdIds: { kind: string; id: number }[] = []
  await history.run({
    label,
    apply: async () => {
      const r = await mapEditApi.batch(ops)
      createdIds = (r.results as Array<{ ok: boolean; action: string; result?: { kind: string; id: number } }>)
        .filter((x) => x.ok && x.action === 'create' && x.result?.id)
        .map((x) => ({ kind: x.result!.kind, id: x.result!.id }))
    },
    revert: async () => {
      const delOps = createdIds.map((c) => ({ action: 'delete' as const, kind: c.kind, id: c.id }))
      if (delOps.length) await mapEditApi.batch(delOps)
    }
  })
  ElMessage.success(`${label} 完成`)
}

// ──────────────────────────────────────────────────────────────────────────
// 属性面板交互
// ──────────────────────────────────────────────────────────────────────────

async function refreshPrimary() {
  if (selection.items.value.length === 0) {
    primary.value = null
    primaryFields.value = []
    primaryMethods.value = []
    primaryStatus.value = []
    return
  }
  const first = selection.items.value[0]!
  primaryLoadingStart()
  try {
    const b = await reflectionApi.getBundle(first.kind, first.id)
    primary.value = b.summary
    primaryFields.value = Object.entries(b.fields).map(([k, v]) => ({ key: k, value: String(v) }))
    primaryMethods.value = b.methods
    primaryStatus.value = (b.status ?? []).map((s) => ({ key: s.key, value: s.value }))
  } catch (err) {
    ElMessage.error(`加载属性失败：${(err as Error).message}`)
  } finally {
    primaryLoadingEnd()
  }
}

function primaryLoadingStart() { propertyLoading.value = true }
function primaryLoadingEnd() { propertyLoading.value = false }

async function onRename(name: string) {
  if (!primary.value || selection.items.value.length === 0) return
  const it = selection.items.value[0]!
  const targetId = primary.value.id
  const oldName = primary.value.name ?? ''
  if (oldName === name) return
  await history.run({
    label: `重命名 ${oldName || '(无)'} → ${name}`,
    apply: async () => {
      await reflectionApi.setField(it.kind, targetId, 'name', name)
    },
    revert: async () => {
      try { await reflectionApi.setField(it.kind, targetId, 'name', oldName) }
      catch (err) { ElMessage.warning(`回滚名称失败：${(err as Error).message}`) }
    }
  })
  ElMessage.success('名称已更新')
}

async function onChangeLayer(layer: string) {
  if (!primary.value || selection.items.value.length === 0) return
  const it = selection.items.value[0]!
  const targetId = primary.value.id
  const oldLayer = primary.value.layer ?? ''
  if (oldLayer === layer) return
  await history.run({
    label: `修改图层 ${oldLayer || '(无)'} → ${layer}`,
    apply: async () => {
      await reflectionApi.setField(it.kind, targetId, 'layerName', layer)
    },
    revert: async () => {
      try { await reflectionApi.setField(it.kind, targetId, 'layerName', oldLayer) }
      catch (err) { ElMessage.warning(`回滚图层失败：${(err as Error).message}`) }
    }
  })
  await refreshPrimary()
}

async function onSetField(key: string, value: string) {
  if (!primary.value) return
  const it = selection.items.value[0]!
  // 抓原值以便 revert 真正回滚
  let oldValue: string | undefined
  try {
    const b = await reflectionApi.getBundle(it.kind, primary.value.id)
    oldValue = b.fields?.[key] != null ? String(b.fields[key]) : undefined
  } catch { /* 抓不到原值就只能不回滚 */ }

  await history.run({
    label: `修改 ${key}`,
    apply: async () => { await reflectionApi.setField(it.kind, primary.value!.id, key, value) },
    revert: async () => {
      if (oldValue !== undefined) {
        try { await reflectionApi.setField(it.kind, primary.value!.id, key, oldValue) }
        catch (err) { ElMessage.warning(`回滚 ${key} 失败：${(err as Error).message}`) }
      } else {
        ElMessage.warning(`回滚 ${key}：没拿到原值快照，仅本地撤销`)
      }
    }
  })
  await refreshPrimary()
}

async function onAddField() {
  try {
    const k = (await ElMessageBox.prompt('字段名', '新增字段', { inputPattern: /^[A-Za-z0-9_]+$/ })).value
    const v = (await ElMessageBox.prompt('字段值', '新增字段', { inputValue: '' })).value
    await onSetField(k, v)
  } catch { /* cancel */ }
}

async function onExecAction(method: string) {
  if (!primary.value) return
  try {
    await reflectionApi.execute(selection.items.value[0]!.kind, primary.value.id, method)
    ElMessage.success(`已执行 ${method}`)
    await refreshPrimary()
  } catch (err) {
    ElMessage.error(`执行失败：${(err as Error).message}`)
  }
}

function onSaveDefaults() {
  localStorage.setItem('mapEditor.defaults', JSON.stringify(defaults))
  ElMessage.success('类型默认已保存到本地')
}

function onLayerToggle(_row: LayerRow) {
  ElMessage.info('图层可见 / 可选切换暂时仅本地生效，后续接入反射 API')
}
function onAddLayer(name: string) {
  if (layerRows.value.find((r) => r.name === name)) return
  layerRows.value = [...layerRows.value, { name, visible: true, selectable: true, color: '#cccccc' }]
  knownLayers.value = [...knownLayers.value, name]
}

// ──────────────────────────────────────────────────────────────────────────
// AI 生图
// ──────────────────────────────────────────────────────────────────────────

async function openAiDialog() {
  try {
    const cfg = await aiConfigApi.get()
    aiConfigured.value = !!cfg.configured
  } catch {
    aiConfigured.value = false
  }
  aiDialogOpen.value = true
}

async function onAiGenerated(r: import('@/api/mapEdit').AiMapGenerateResult) {
  refreshAfterMutation()

  // 把本次 AI 落地的所有对象登记到撤销栈：用户一次 Ctrl+Z 即可整体回退。
  // 注意：r.created 里既有 ok=true 的成功项，也可能有 { tool, error } 的失败项；只挑成功项删除。
  type CreatedObj = { kind: string; id: number; tool: string; typeName?: string; error?: string }
  const ok: Array<CreatedObj> = (r.created ?? [])
    .filter((c): c is CreatedObj => typeof c === 'object' && c != null && typeof (c as { id?: unknown }).id === 'number' && !c.error)
  if (ok.length === 0) return

  // 直接 push 一条「假装已 apply」的命令到栈：apply 是 noop（落地已发生），revert 是批量删除。
  // 这样用户不需要在生成对话框里再确认一次。
  const cmd = {
    label: `AI 生图：落地 ${ok.length} 个对象`,
    apply: async () => { /* already applied by backend */ },
    revert: async () => {
      const ops = ok.map((c) => ({ action: 'delete' as const, kind: c.kind, id: c.id }))
      await mapEditApi.batch(ops)
      ElMessage.success(`已撤销 AI 生图（删除 ${ops.length} 个对象）`)
    }
  }
  // 走 history.run 会再次调用 apply（noop），所以直接 await cmd.apply() + 手动入栈。
  await history.run(cmd)
}

// ──────────────────────────────────────────────────────────────────────────
// 项目保存：调反射 API 触发 SimpleProject.Save (如有 MethodMember 暴露)
// ──────────────────────────────────────────────────────────────────────────

async function onProjectSave(saveAs: boolean) {
  const cached = localStorage.getItem('mapEditor.lastProjectPath') ?? ''

  // 非另存为且有缓存：直接覆盖保存到上次路径，无需弹对话框。
  if (!saveAs && cached) {
    saving.value = true
    try {
      const r = await mapEditApi.projectSave(cached)
      localStorage.setItem('mapEditor.lastProjectPath', r.path)
      ElMessage.success(`已保存：${r.path}`)
    } catch (err) {
      ElMessage.error(`保存失败：${(err as Error).message}`)
    } finally {
      saving.value = false
    }
    return
  }

  // 另存为或首次保存：弹服务端目录浏览对话框（项目文件保存在 SimpleLite 服务端）。
  projectBrowseMode.value = 'save'
  projectBrowseInitial.value = cached ? cached.replace(/[\\/][^\\/]*$/, '') : ''
  projectBrowseOpen.value = true
}

async function onProjectOpen() {
  const cached = localStorage.getItem('mapEditor.lastProjectPath') ?? ''
  projectBrowseMode.value = 'load'
  projectBrowseInitial.value = cached ? cached.replace(/[\\/][^\\/]*$/, '') : ''
  projectBrowseOpen.value = true
}

async function onProjectBrowseConfirm(path: string) {
  if (!path) return
  if (projectBrowseMode.value === 'save') {
    saving.value = true
    try {
      const r = await mapEditApi.projectSave(path)
      localStorage.setItem('mapEditor.lastProjectPath', r.path)
      ElMessage.success(`已保存：${r.path}`)
    } catch (err) {
      ElMessage.error(`保存失败：${(err as Error).message}`)
    } finally {
      saving.value = false
    }
    return
  }
  try {
    const r = await mapEditApi.projectLoad(path)
    localStorage.setItem('mapEditor.lastProjectPath', r.path)
    ElMessage.success(`已加载：${r.sites} 站点 / ${r.tracks} 路径 / ${r.specials} 装饰 / ${r.missions} 任务`)
    selection.clear()
    primary.value = null
  } catch (err) {
    ElMessage.error(`加载失败：${(err as Error).message}`)
  }
}

// ──────────────────────────────────────────────────────────────────────────
// 资产上传
// ──────────────────────────────────────────────────────────────────────────

function triggerUpload(kind: 'image' | 'model') {
  fileUploadKind.value = kind
  fileAcceptHint.value = kind === 'image'
    ? 'image/*'
    : '.obj,.glb,.gltf,.fbx,.cyclemodel'
  fileInputRef.value?.click()
}

async function onFilePicked(ev: Event) {
  const input = ev.target as HTMLInputElement
  const file = input.files?.[0]
  if (!file) return
  try {
    const buf = await file.arrayBuffer()
    const b64 = arrayBufferToBase64(buf)
    const r = await mapEditApi.uploadAsset(file.name, b64)
    ElMessage.success(`已上传：${r.fileName}`)
    if (fileUploadKind.value === 'image') {
      // 简化：直接在原点创建图片对象
      await mapEditApi.createImage({ x: 0, y: 0, imagePath: r.savedPath, size: 1000 })
    } else {
      await mapEditApi.createModel({ x: 0, y: 0, modelPath: r.savedPath, scale: 1 })
    }
    refreshAfterMutation()
  } catch (err) {
    ElMessage.error(`上传失败：${(err as Error).message}`)
  } finally {
    input.value = ''
  }
}

function arrayBufferToBase64(buffer: ArrayBuffer): string {
  let binary = ''
  const bytes = new Uint8Array(buffer)
  for (let i = 0; i < bytes.byteLength; i++) binary += String.fromCharCode(bytes[i]!)
  return btoa(binary)
}

// ──────────────────────────────────────────────────────────────────────────
// SSE 订阅 + 画布桥（鼠标坐标）
// ──────────────────────────────────────────────────────────────────────────

useCanvasBridge({
  onMouse: (p) => { mouseXY.x = p.x; mouseXY.y = p.y },
  onPick: (p) => { onCanvasPick(p) },
  onSelect: (p) => onCanvasSelect(p.names)
})

const stream = useMapEditStream({
  onAlarm: () => { /* 编辑器不展示报警，由 Monitor 页订阅 */ },
  onObjectCreated: () => refreshAfterMutation(),
  onObjectDeleted: () => refreshAfterMutation(),
  onObjectPatched: () => refreshAfterMutation(),
  onObjectBatchChanged: () => refreshAfterMutation(),
  onSelectionDetail: (e) => {
    // 把 SimpleLite 端 canvas 内的选中实时同步到 Vue 端，让右侧属性面板自动刷新。
    //
    // 重要：仅在 e.names 是数组时才覆盖 selection。
    // 历史教训：早期某些后端路径（ReflectionApiController.SetSelection / ClearSelection）
    // 广播的 selection-detail 缺少 names 字段，e.names 为 undefined。
    // 若此时直接 `e.names ?? []` 会把刚选中的对象误清空，进一步触发 watch 把 primary 设 null
    // → 右侧属性面板瞬时显示 "未选中对象"。所以这里非数组直接忽略，保留当前 selection。
    if (!Array.isArray(e.names)) return
    const incoming = parseSelectionNames(e.names)
    const incomingKey = makeSelectionKey(incoming)
    // 防回环：广播回来的就是本地刚推上去的同一份，跳过避免死循环
    // watch(selection) → reflectionApi.setSelection → 后端广播 selection-detail → 这里。
    if (incomingKey === lastSyncedSelectionKey.value) return
    lastSyncedSelectionKey.value = incomingKey
    selection.set(incoming)
  }
})

function refreshAfterMutation() {
  // 简化：刷新当前选中的属性
  refreshPrimary()
}

watch(() => stream.connected.value, (v) => (streamConnected.value = v), { immediate: true })

watch(() => makeSelectionKey(selection.items.value), (key) => {
  refreshPrimary()
  // 记录"本地刚发出"的 key 给 onSelectionDetail 用，无论单选/多选都先记下
  // （setSelection 是 fire-and-forget，但后端广播一定晚于本调用，因此 last
  // SyncedSelectionKey 在广播到达时已就绪）。
  lastSyncedSelectionKey.value = key
  // 把后端选中状态同步到 SimpleLite（单选）
  if (selection.items.value.length === 1) {
    reflectionApi.setSelection(selection.items.value[0]!.kind, selection.items.value[0]!.id).catch(() => {})
  }
})

// ──────────────────────────────────────────────────────────────────────────
// 键盘快捷键
// ──────────────────────────────────────────────────────────────────────────

function onKeyDown(ev: KeyboardEvent) {
  const meta = ev.ctrlKey || ev.metaKey
  if (meta && ev.key.toLowerCase() === 'z' && !ev.shiftKey) {
    ev.preventDefault(); history.undo()
  } else if (meta && (ev.key.toLowerCase() === 'y' || (ev.key.toLowerCase() === 'z' && ev.shiftKey))) {
    ev.preventDefault(); history.redo()
  } else if (ev.key === 'Delete') {
    deleteSelection()
  }
}

/**
 * 从后端拉当前的 viewFilter 真实状态，把本地 reactive 覆盖。
 * SimpleLite 是 source of truth：避免「前端勾着、SimpleLite 真实开关却没改」的偏差
 * （比如另一个 tab 已经把过滤打开 / 关掉，本 tab 进入时应当反映那次状态）。
 * 失败时静默回退到本地 localStorage 缓存（loadViewFilter 已先调过）。
 */
async function syncViewFilterFromBackend() {
  try {
    const snap = await mapEditApi.getViewFilter()
    Object.assign(viewFilter.selectFilter, snap.selectFilter)
    Object.assign(viewFilter.alignSnap, snap.alignSnap)
    Object.assign(viewFilter.showViewport, snap.showViewport)
    persistViewFilter()
  } catch (err) {
    console.warn('[MapEditor] 拉后端 viewFilter 失败，沿用本地缓存：', err)
  }
}

onMounted(async () => {
  window.addEventListener('keydown', onKeyDown)
  try {
    const cfg = await aiConfigApi.get()
    aiConfigured.value = !!cfg.configured
  } catch { /* offline */ }
  const cached = localStorage.getItem('mapEditor.defaults')
  if (cached) {
    try { Object.assign(defaults, JSON.parse(cached)) } catch { /* ignore */ }
  }
  loadViewFilter()
  await syncViewFilterFromBackend()
})

onUnmounted(() => {
  window.removeEventListener('keydown', onKeyDown)
})
</script>

<style scoped>
.map-editor-page {
  height: calc(100vh - 56px);
  display: flex; flex-direction: column;
  background: radial-gradient(ellipse at 50% 40%, #2d1456 0%, #1a0930 50%, #0f0420 100%);
  color: rgba(232,215,245,0.9);
}
.me-body {
  flex: 1;
  display: flex;
  min-height: 0;
}
.me-center {
  flex: 1;
  min-width: 0;
  display: flex;
}
.me-center :deep(.workspace-3d-wrap) {
  flex: 1;
  height: 100%;
  border: 0;
  border-radius: 0;
  min-height: 0;
}
</style>
