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

    <div class="me-body">
      <EditToolRail
        :active-tool="editTool.activeTool.value"
        :active-select-only="activeSelectOnly"
        @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"
        :selection-kind="selectionKind"
        :car-type="selectionCarType"
        :viewport-style="viewportStyle"
        :viewport-style-loading="viewportStyleLoading"
        :viewport-syncing="viewportSyncing"
        @rename="onRename"
        @change-layer="onChangeLayer"
        @set-field="onSetField"
        @delete-field="onDeleteField"
        @exec-action="onExecAction"
        @save-defaults="onSaveDefaults"
        @layer-toggle="onLayerToggle"
        @add-layer="onAddLayer"
        @delete="deleteSelection"
        @patch-viewport="onPatchViewport"
      />

      <!-- 右侧「AI 助手」：悬浮入口手柄 + 固定停靠聊天面板（实底，可发送文字需求） -->
      <button
        class="ai-assistant-fab"
        :class="{ 'is-open': aiAssistantOpen }"
        :style="{ right: aiAssistantOpen ? `${aiPanelWidth}px` : '0' }"
        type="button"
        :title="aiAssistantOpen ? '收起 AI 助手' : '打开 AI 助手'"
        @click="toggleAiAssistant"
      >
        <span class="fab-glyph">*</span>
        <span class="fab-text">AI<br>助手</span>
      </button>

      <AiAssistantPanel
        v-model:open="aiAssistantOpen"
        v-model:width="aiPanelWidth"
        :configured="aiConfigured"
        :default-bounds="defaultAiBounds"
        :default-layer="defaults.site.layer"
        @generated="onAiGenerated"
      />
    </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.undoCount.value"
      :redo-size="history.redoCount.value"
      :undo-limit="history.maxDepth"
      :connected="streamConnected"
    />

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

    <!--
      图层显示切换对话框：列出所有已知图层，每行一个 visible/selectable 开关 +
      「设为当前默认层」按钮。本面板与右侧 EditPropertyPanel 的图层卡片共享同一份
      layerRows，操作后双向同步；defaults.site/track.layer 决定下次创建对象的落点。
      备注：画布渲染本身受 Simple3 的 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"
    />

    <BetterAddFieldDialog
      v-model:open="betterAddFieldOpen"
      :targets="betterAddFieldTargets"
      :target-summary="betterAddFieldSummary"
      @applied="refreshAfterMutation"
    />
  </div>
</template>

<script setup lang="ts">
import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
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 AiAssistantPanel from '@/components/map-editor/AiAssistantPanel.vue'
import BetterAddFieldDialog from '@/components/map-editor/BetterAddFieldDialog.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, mapsApi } from '@/api/mapEdit'
import {
  promptNewSaveName,
  promptSaveMode,
  resolveCurrentMapName
} from '@/utils/projectSaveFlow'
import { defaultVrHost } from '@/utils/vrender'
import {
  reflectionApi,
  normalizeViewportPayload,
  type ReflectionMethod,
  type ReflectionObject,
  type ReflectionKind,
  type ViewportStylePayload,
  type ViewportStyleSite,
  type ViewportStyleTrack
} from '@/api/reflection'

const auth = useAuthStore()
const route = useRoute()
const router = useRouter()
const vrHost = defaultVrHost()

// 当前正在编辑的「固定文件夹地图名」。从地图管理页带 ?map=<name> 进入时载入；
// 决定保存时的默认名称与「是否替换原地图」确认逻辑。新建地图（?new=1）时为空?
const editingMapName = ref<string>('')

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 pendingImagePath = ref<string | null>(null)
/** 装饰文本：确认内容后待落?*/
const pendingTextContent = ref<string | null>(null)

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

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

const aiDialogOpen = ref(false)
// 右侧固定停靠的「AI 助手」聊天面板开关（?AI 生图对话框相互独立）?
// 开关状态与面板宽度都持久化?localStorage，下次进入编辑器自动恢复?
const aiAssistantOpen = ref(false)
const aiPanelWidth = ref(360)
const AI_PANEL_OPEN_KEY = 'mapEditor.aiAssistant.open'
const AI_PANEL_WIDTH_KEY = 'mapEditor.aiAssistant.width'
const aiConfigured = ref(false)
const defaultAiBounds = ref<[number, number, number, number]>([-10000, -10000, 10000, 10000])

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 }>>([])

// 默认图层?Simple3/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 viewportStyle = ref<ViewportStylePayload | null>(null)
const viewportStyleLoading = ref(false)
const viewportSyncing = ref(false)
let viewportPatchTimer: ReturnType<typeof setTimeout> | null = null
let viewportPatchSeq = 0
let pendingViewportPatch: { site?: ViewportStyleSite; track?: ViewportStyleTrack } = {}

/** 单选且?site/track/car 时驱动属性扩展；类型默认 Tab 仍只支持 site/track?*/
const selectionKind = computed<'site' | 'track' | 'car' | null>(() => {
  const items = selection.items.value
  if (items.length !== 1) return null
  const k = items[0]!.kind
  if (k === 'site' || k === 'track') return k
  if (k === 'car') return 'car'
  return null
})

/** 当前选中车的车型唯一标识（用?simple_fields.carFields 过滤）?*/
const selectionCarType = computed<string | null>(() => {
  if (selectionKind.value !== 'car') return null
  // 选中车后 primary 会刷新为真实对象摘要，typeName ?selection item 更可靠（不是 "Car" 占位名）?
  return primary.value?.typeName?.trim() || null
})

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' }
])

// 「过滤」菜单状态：对齐 Simple3 桌面?WorkspaceBottomBar 的「选择 / 对齐 / 显示内容」三组开关?
// 三组语义?
//   selectFilter.*  画布命中后允许进?selection 的对象类型，?SimpleUI.SelectFilter{Sites,Tracks,Cars,Decor} 对齐?
//   alignSnap.*     绘制工具吸附时考虑的目标类型，?SimpleUI.AlignSnap{Sites,Cars,Tracks} 对齐?
//   showViewport.*  显示内容过滤，与 SimpleUI.ShowViewport{SceneLabels,ScenePrimitives,Cars} 对齐?
//
// 当前阶段仅在 Vue 前端生效（影?selection 接收 / 属性面板筛选）；画布本身的可见性由 Simple3
// ?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 }
})

/**
 * 路径子类型过滤（后端 SelectFilter 只有 tracks 总开关）?
 * - all：不限制直线/曲线
 * - straight：仅 UITrack
 * - curve：贝塞尔 / ?/ NURBS
 * 由左侧「仅直线 / 仅曲线」驱动?
 */
const trackSelectMode = ref<'all' | 'straight' | 'curve'>('all')

type SelectOnlyMode = 'site' | 'straight' | 'curve' | 'decor'

function isCurveTrackType(typeName: string): boolean {
  const tn = typeName.toLowerCase()
  return tn.includes('bezier') || tn.includes('arc') || tn.includes('nurbs')
}

function isStraightTrackType(typeName: string): boolean {
  const tn = typeName.toLowerCase()
  return tn.includes('track') && !isCurveTrackType(typeName)
}

function trackPassesSelectMode(typeName: string): boolean {
  if (trackSelectMode.value === 'straight') return isStraightTrackType(typeName)
  if (trackSelectMode.value === 'curve') return isCurveTrackType(typeName)
  return true
}

/** 当前是否处于左侧「仅*」互斥模式（用于工具栏高亮）?*/
const activeSelectOnly = computed<SelectOnlyMode | null>(() => {
  const sf = viewFilter.selectFilter
  if (sf.sites && !sf.tracks && !sf.cars && !sf.decor) return 'site'
  if (!sf.sites && sf.tracks && !sf.cars && !sf.decor) {
    if (trackSelectMode.value === 'straight') return 'straight'
    if (trackSelectMode.value === 'curve') return 'curve'
  }
  if (!sf.sites && !sf.tracks && !sf.cars && sf.decor) return 'decor'
  return null
})

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 里这类对象剔掉?
 * 否则之前选中的、现在不该被选的会一直留在右侧属性面板，体验突兀?
 * 同时尊重 trackSelectMode（仅直线 / 仅曲线）?
 */
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 && trackPassesSelectMode(it.typeName)
    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?
// 注：画布渲染本身?Simple3 ?LayerToShow 控制，目前后端没有暴露写 API?
// 这里 toggle 仅控制前端面板的可见性筛选（同步到画布需另加后端 API，待 Fody 修好再做）?
const layerVisibilityDialogOpen = ref(false)

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

const streamConnected = ref(false)

// SSE 与本地选中同一份时跳过 selection.set，避免重复拉属性
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)
let pickLoopGen = 0
let pickLoopTask: Promise<void> | null = null
let pickAbort: AbortController | null = null
const pickToolIds: EditToolId[] = [
  'draw.site',
  'draw.sites.continuous',
  'draw.track.polyline',
  'draw.tracks.continuous',
  'draw.track.bezier',
  'draw.track.arc',
  'draw.track.nurbs',
  'draw.decor.text',
  'draw.image'
]

/** 布线取端点需要站点吸附；站点/文本/图片落点必须关吸附，否则会被附近站点吸走?*/
function pickNeedsSiteSnap(tool: EditToolId): boolean {
  return (
    tool === 'draw.track.polyline'
    || tool === 'draw.tracks.continuous'
    || tool === 'draw.track.bezier'
    || tool === 'draw.track.arc'
    || tool === 'draw.track.nurbs'
  )
}

async function stopPickLoop() {
  pickLoopGen++
  pickLoopActive.value = false
  pickAbort?.abort()
  pickAbort = null
  try {
    await mapEditApi.cancelPick()
  } catch {
    /* 没有进行中的拾取，或内核尚未提供 cancel */
  }
  if (pickLoopTask) {
    try { await pickLoopTask } catch { /* ignore */ }
    pickLoopTask = null
  }
}

async function startPickLoop() {
  await stopPickLoop()
  const myGen = pickLoopGen
  if (!pickToolIds.includes(editTool.activeTool.value)) return
  if (pickLoopGen !== myGen) return

  pickAbort = new AbortController()
  const signal = pickAbort.signal
  pickLoopActive.value = true
  const task = runPickLoop(myGen, signal)
  pickLoopTask = task
  void task.finally(() => {
    if (pickLoopTask === task) pickLoopTask = null
  })
}

async function runPickLoop(myGen: number, signal: AbortSignal) {
  try {
    while (pickLoopGen === myGen) {
      let result: { x: number; y: number; siteId: number } | null = null
      const toolBeforePick = editTool.activeTool.value
      try {
        result = await mapEditApi.pick({ snap: pickNeedsSiteSnap(toolBeforePick), signal })
      } catch {
        break
      }
      if (pickLoopGen !== myGen || !result) 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)
      } else if (
        tool === 'draw.track.polyline'
        || tool === 'draw.tracks.continuous'
        || tool === 'draw.track.bezier'
        || tool === 'draw.track.arc'
        || tool === 'draw.track.nurbs'
      ) {
        const created = await handleTrackPick(result.siteId)
        if (created) {
          const label = tool === 'draw.track.bezier' ? '贝塞尔路径'
            : tool === 'draw.track.arc' ? '弧形路径'
            : tool === 'draw.track.nurbs' ? 'NURBS 路径'
            : '直线路径'
          ElMessage.success(`已添加${label}`)
          if (tool !== 'draw.tracks.continuous') break
        }
      } else if (tool === 'draw.decor.text') {
        const content = pendingTextContent.value || 'Label'
        pendingTextContent.value = null
        await runCreateText(result.x, result.y, content)
        ElMessage.success(`已添加文本「${content}」`)
        break
      } else if (tool === 'draw.image') {
        const path = pendingImagePath.value
        if (!path) {
          ElMessage.warning('请先选择要插入的图片')
          break
        }
        pendingImagePath.value = null
        await runCreateImage(result.x, result.y, path)
        ElMessage.success('已插入图片')
        break
      } else {
        break
      }
    }
  } finally {
    if (pickLoopGen === myGen) pickLoopActive.value = false
  }
}

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.copy': await copySelection(); break
    case 'edit.paste': await pasteClipboard(); break
    case 'edit.cut': await cutSelection(); break
    case 'edit.selectAll': ElMessage.info('全选：请使用工具栏「按类型选」批量选中'); break
    case 'edit.invert': ElMessage.info('反选功能后续实现'); break
    case 'project.save': await saveMapAndLeave(); break
    case 'ai.open': await openAiDialog(); 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
    // 「图层」菜单：新建图层 / 切换图层显示
    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` 把变化写?Simple3 后端?
 *     selectFilter ?SimpleUI.SelectFilter* + RebuildWorkspaceSelectablePatterns（影响画布拾?+ hover?
 *     alignSnap   ?SimpleUI.AlignSnap*（影响绘制工具吸附）
 *     showViewport ?SimpleUI.ShowViewport*（影响画布渲染：取消勾选后对应对象消失?
 *   后端会触?SimpleSceneRenderer.NotifyStateChanged() 立即重画画布?
 * - 三组都会写一?localStorage 记住偏好?
 *
 * E2 修复：后端请求失败时回滚本地状?+ localStorage，让菜单勾选与"实际生效的过?保持一?—?
 * 否则用户看到菜单?ON、画布表现是 OFF，会以为 Simple3 配置错乱。回滚后再弹一条带"重试"按钮的提示?
 */
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]
  // 顶栏手动改选择过滤时，退出「仅直线/仅曲线」细分，避免与总开关语义冲?
  const prevTrack = trackSelectMode.value
  if (group === 'selectFilter') trackSelectMode.value = 'all'
  persistViewFilter()
  if (group === 'selectFilter') pruneSelectionByFilter()

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

async function applySelectOnlyMode(mode: SelectOnlyMode) {
  const allOn = { sites: true, tracks: true, cars: true, decor: true }
  const exclusive: Record<SelectOnlyMode, Omit<ViewFilterState['selectFilter'], 'trackMode'>> = {
    site: { sites: true, tracks: false, cars: false, decor: false },
    straight: { sites: false, tracks: true, cars: false, decor: false },
    curve: { sites: false, tracks: true, cars: false, decor: false },
    decor: { sites: false, tracks: false, cars: false, decor: true }
  }
  const turningOff = activeSelectOnly.value === mode
  const nextSf = turningOff ? allOn : exclusive[mode]
  const nextTrack: typeof trackSelectMode.value =
    turningOff ? 'all' : mode === 'straight' ? 'straight' : mode === 'curve' ? 'curve' : 'all'

  const prevSf = { ...viewFilter.selectFilter }
  const prevTrack = trackSelectMode.value
  Object.assign(viewFilter.selectFilter, nextSf)
  trackSelectMode.value = nextTrack
  persistViewFilter()
  pruneSelectionByFilter()

  try {
    await mapEditApi.setViewFilter({
      selectFilter: { ...nextSf, trackMode: nextTrack }
    })
  } catch (err) {
    console.warn('[MapEditor] 写后端 selectOnly 失败：', err)
    Object.assign(viewFilter.selectFilter, prevSf)
    trackSelectMode.value = prevTrack
    persistViewFilter()
    pruneSelectionByFilter()
    ElMessage({
      message: '选择过滤写入后端失败，已回滚；请检查 Simple3 是否连通后重试。',
      type: 'warning',
      duration: 5000,
      showClose: true
    })
  }
}

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

// 把指定图层设为下次创建对象的默认落点。也作为 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 onSelectTool(id: EditToolId) {
  // 「仅*」是选择过滤开关，不抢占当前绘?变换工具，也不打?pick 循环
  if (id.startsWith('select.byType.')) {
    const t = id.replace('select.byType.', '')
    const mode: SelectOnlyMode | null =
      t === 'site' || t === 'decor' || t === 'straight' || t === 'curve' ? t
      : t === 'polyline' ? 'straight'
      : t === 'bezier' || t === 'arc' ? 'curve'
      : null
    if (mode) await applySelectOnlyMode(mode)
    return
  }

  editTool.setTool(id)
  drawTrackState.value.siteA = null
  // 切走文本/图片工具时清掉未完成的落点载荷，避免下次误用
  if (id !== 'draw.decor.text') pendingTextContent.value = null
  if (id !== 'draw.image') pendingImagePath.value = null

  if (id === 'select.clear') {
    await stopPickLoop()
    selection.clear()
    await reflectionApi.clearSelection().catch(() => {})
    return
  }
  if (id === 'draw.decor.text') {
    try {
      const { value } = await ElMessageBox.prompt('请输入文本内容', '装饰文本', {
        inputValue: 'Label',
        confirmButtonText: '下一步：点选落点',
        cancelButtonText: '取消'
      })
      pendingTextContent.value = (value ?? '').trim() || 'Label'
    } catch {
      editTool.setTool('select.single')
      await stopPickLoop()
      return
    }
    await startPickLoop()
    return
  }
  if (id === 'draw.image') {
    await stopPickLoop()
    triggerUpload('image')
    return
  }
  if (pickToolIds.includes(id)) {
    await startPickLoop()
  } else {
    await stopPickLoop()
  }
  if (id.startsWith('align.')) {
    await applyAlignment(id.replace('align.', '') as AlignMode)
    return
  }
  if (id.startsWith('batch.')) {
    await runBatchGenerate(id)
    return
  }
  if (id === 'transform.copy') { await copySelection(); return }
  if (id === 'transform.paste') { await pasteClipboard(); return }
  if (id === 'transform.duplicate') { await duplicateSelection(); return }
  if (id === 'transform.delete') {
    await deleteSelection()
    return
  }
  if (id === 'show.controlPoints') editTool.showControlPoints.value = !editTool.showControlPoints.value
  if (id === 'show.controlHandles') editTool.showControlHandles.value = !editTool.showControlHandles.value
  if (id === 'transform.copyFields') { openBetterAddFieldDialog(); return }
}

// ──────────────────────────────────────────────────────────────────────────
// 画布事件：根据当前工具决?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

  if (id === 'draw.site' || id === 'draw.sites.continuous') {
    await runCreateSite(p.x, p.y)
    return
  }
  if (
    id === 'draw.track.polyline'
    || id === 'draw.tracks.continuous'
    || id === 'draw.track.bezier'
    || id === 'draw.track.arc'
    || id === 'draw.track.nurbs'
  ) {
    try {
      const pick = await mapEditApi.pick({ snap: true })
      await handleTrackPick(pick.siteId)
    } catch (err) {
      const msg = err instanceof Error ? err.message : String(err)
      return
    }
    return
  }
}

function applySelectionItems(items: SelectionItem[]) {
  const key = makeSelectionKey(items)
  if (key === lastSyncedSelectionKey.value) return
  lastSyncedSelectionKey.value = key
  selection.set(items)
}

function isMapEditorKind(kind: string | null | undefined): kind is ReflectionKind {
  return kind === 'site' || kind === 'track' || kind === 'car' || kind === 'special'
}

let selectionBroadcastSeq = 0

async function resolveEmptySelectionBroadcast(seq: number) {
  try {
    const sel = await reflectionApi.getSelection()
    if (seq !== selectionBroadcastSeq) return
    if (isMapEditorKind(sel?.kind) && sel.id) {
      applySelectionItems([{
        kind: sel.kind,
        id: sel.id,
        name: sel.name || `${sel.kind}-${sel.id}`,
        typeName: sel.typeName || sel.kind
      }])
      return
    }
  } catch {
    if (seq !== selectionBroadcastSeq) return
  }
  if (seq !== selectionBroadcastSeq) return
  applySelectionItems([])
}

function onCanvasSelect(names: string[]) {
  const items = parseSelectionNames(names)
  if (items.length === 0) return
  applySelectionItems(items)
}

/** 画布/SSE 对象名 → SelectionItem；曲线段、句柄、字形/贴图归并到父对象。 */
function parseOneSelectionName(n: string): SelectionItem | null {
  const handle = /^CurveHandle-(\d+)_/.exec(n)
  if (handle) {
    return {
      kind: 'track',
      id: Number(handle[1]),
      name: n,
      typeName: 'UIBezierTrack'
    }
  }

  const text = /^TxtGlyph-(\d+)$/.exec(n) ?? /^UIText-glyph-(\d+)$/.exec(n)
  if (text) {
    return { kind: 'special', id: Number(text[1]), name: n, typeName: 'UIText' }
  }

  const image = /^ImgTex-(\d+)$/.exec(n)
    ?? /^UIImage-(?:tex|rgba)-(\d+)$/.exec(n)
  if (image) {
    return { kind: 'special', id: Number(image[1]), name: n, typeName: 'UIImage' }
  }

  const siteLabel = /^SiteSelLabel-(\d+)$/.exec(n)
  if (siteLabel) {
    return { kind: 'site', id: Number(siteLabel[1]), name: n, typeName: 'UISite' }
  }

  const m = /^UI([A-Za-z]+)-(\d+)(?:_seg\d+)?$/.exec(n)
    ?? /^([A-Za-z]+)-(\d+)(?:_seg\d+)?$/.exec(n)
  if (!m) return null

  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'

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

function parseSelectionNames(names: readonly string[]): SelectionItem[] {
  const items: SelectionItem[] = []
  const seen = new Set<string>()
  const sf = viewFilter.selectFilter
  for (const n of names) {
    const parsed = parseOneSelectionName(n)
    if (!parsed) continue
    const dedupe = `${parsed.kind}:${parsed.id}`
    if (seen.has(dedupe)) continue
    seen.add(dedupe)

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

    items.push(parsed)
  }
  return items
}

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

  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' // polyline / tracks.continuous 都走直线 UITrack

  let midCount = 2
  if (kind === 'bezier' || kind === 'nurbs') {
    const n = await promptCurveMidCount(kind)
    if (n === 'cancelled') {
      drawTrackState.value.siteA = null
      return false
    }
    midCount = n
  }

  drawTrackState.value.siteA = null

  let lastId = -1
  await history.run({
    label: `新建${trackKindLabel(kind)} (${a}->${b})`,
    apply: async () => {
      const r = await mapEditApi.createTrack(kind, { siteA: a, siteB: b, layer: defaults.track.layer })
      lastId = r.id
      if ((kind === 'bezier' || kind === 'nurbs') && midCount !== 2) {
        await applyCurveMidCount(lastId, kind, midCount, a, b)
      }
    },
    revert: async () => {
      if (lastId > 0) await mapEditApi.deleteObject('track', lastId)
    }
  })
  return true
}

async function promptCurveMidCount(kind: 'bezier' | 'nurbs'): Promise<number | 'cancelled'> {
  try {
    const v = await ElMessageBox.prompt(
      '请输入中间控制点个数（不含起止站点；默认 2，按邻边切向自动生成，无需在画布上点选）',
      `创建${trackKindLabel(kind)}`,
      { inputValue: '2', inputPattern: /^\d+$/, inputErrorMessage: '请输入非负整数' }
    )
    return Math.max(0, Math.floor(Number(v.value)))
  } catch {
    return 'cancelled'
  }
}

interface XYPoint { x: number; y: number }

function parseCurveTypeInfo(ti: string): { flag: 2 | 3; points: XYPoint[] } | null {
  const parts = ti.split(',').map((s) => s.trim())
  if (parts.length < 4) return null
  const flag = Number(parts[0])
  const count = Number(parts[1])
  if ((flag !== 2 && flag !== 3) || !Number.isFinite(count) || count < 2) return null
  const points: XYPoint[] = []
  for (let i = 0; i < count; i++) {
    const x = Number(parts[2 + i * 2])
    const y = Number(parts[3 + i * 2])
    if (!Number.isFinite(x) || !Number.isFinite(y)) return null
    points.push({ x, y })
  }
  return { flag, points }
}

function formatCurveTypeInfo(flag: 2 | 3, points: XYPoint[]): string {
  const parts: string[] = [String(flag), String(points.length)]
  for (const p of points) {
    parts.push(p.x.toFixed(3), p.y.toFixed(3))
  }
  return parts.join(',')
}

function deCasteljau(points: XYPoint[], t: number): XYPoint {
  const buf = points.map((p) => ({ x: p.x, y: p.y }))
  for (let k = 1; k < buf.length; k++) {
    for (let i = 0; i < buf.length - k; i++) {
      buf[i].x = (1 - t) * buf[i].x + t * buf[i + 1].x
      buf[i].y = (1 - t) * buf[i].y + t * buf[i + 1].y
    }
  }
  return buf[0]!
}

function elevateBezier(pts: XYPoint[]): XYPoint[] {
  const n = pts.length - 1
  const out: XYPoint[] = [{ ...pts[0]! }]
  for (let i = 1; i <= n; i++) {
    const a = i / (n + 1)
    out.push({
      x: a * pts[i - 1]!.x + (1 - a) * pts[i]!.x,
      y: a * pts[i - 1]!.y + (1 - a) * pts[i]!.y
    })
  }
  out.push({ ...pts[n]! })
  return out
}

function reshapeCurvePoints(pts: XYPoint[], midCount: number): XYPoint[] {
  if (pts.length < 2) return pts
  const a = pts[0]!
  const b = pts[pts.length - 1]!
  if (midCount <= 0) return [a, b]
  if (midCount === 1) {
    const mid = deCasteljau(pts, 0.5)
    return [a, { x: 2 * mid.x - (a.x + b.x) / 2, y: 2 * mid.y - (a.y + b.y) / 2 }, b]
  }
  let cur = pts.length >= 4
    ? pts
    : [
        a,
        { x: a.x + (b.x - a.x) / 3, y: a.y + (b.y - a.y) / 3 },
        { x: a.x + (2 * (b.x - a.x)) / 3, y: a.y + (2 * (b.y - a.y)) / 3 },
        b
      ]
  const targetLen = midCount + 2
  while (cur.length < targetLen) cur = elevateBezier(cur)
  return cur
}

async function applyCurveMidCount(
  trackId: number,
  kind: 'bezier' | 'nurbs',
  midCount: number,
  siteA: number,
  siteB: number
): Promise<void> {
  const flag: 2 | 3 = kind === 'nurbs' ? 3 : 2
  let pts: XYPoint[] | null = null
  try {
    const bundle = await reflectionApi.getBundle('track', trackId)
    pts = parseCurveTypeInfo(bundle.fields?.typeInfo ?? '')?.points ?? null
  } catch { /* 用两端站点坐标兜底 */ }
  if (!pts || pts.length < 2) {
    const sa = await fetchSiteXY(siteA)
    const sb = await fetchSiteXY(siteB)
    if (!sa || !sb) return
    pts = [sa, sb]
  }
  const next = reshapeCurvePoints(pts, midCount)
  await reflectionApi.setField('track', trackId, 'typeInfo', formatCurveTypeInfo(flag, next))
}

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 }
}

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

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 runCreateText(x: number, y: number, content: string) {
  let createdId = -1
  await history.run({
    label: `新建文本「${content}」`,
    apply: async () => {
      const r = await mapEditApi.createText({
        x,
        y,
        content,
        layer: defaults.site.layer,
        color: '#ffffff',
        size: 16
      })
      createdId = r.id
    },
    revert: async () => {
      if (createdId > 0) await mapEditApi.deleteObject('text', createdId)
    }
  })
}

async function runCreateImage(x: number, y: number, imagePath: string) {
  let createdId = -1
  await history.run({
    label: '插入图片',
    apply: async () => {
      const r = await mapEditApi.createImage({
        x,
        y,
        imagePath,
        size: 1200,
        layer: defaults.site.layer
      })
      createdId = r.id
    },
    revert: async () => {
      if (createdId > 0) await mapEditApi.deleteObject('image', createdId)
    }
  })
}

type ObjectSnapshot = { kind: string; typeName: string; data: Record<string, unknown> }

const PASTE_OFFSET_MM = 200
/** 粘贴只带可安全重建的字段，避?bundle 全量字段污染新建对象、打?Web 通道?*/
const PASTE_FIELD_ALLOW = new Set([
  'x', 'y', 'z', 'name', 'layer', 'layerName', 'color', 'displaySetting',
  'siteA', 'siteB', 'typeInfo', 'direction',
  'content', 'size', 'imagePath', 'angle', 'opacity', 'modelPath', 'scale', 'rotation', 'theta'
])
/** 删除撤销需恢复自定义字段；排除运行时态与主键?*/
const DELETE_SNAPSHOT_FIELD_DENY = new Set(['id', 'tags'])
const editorClipboard = ref<ObjectSnapshot[]>([])
const betterAddFieldOpen = ref(false)

const betterAddFieldTargets = computed(() =>
  selection.items.value.map((it) => ({ kind: it.kind, id: it.id }))
)

const betterAddFieldSummary = computed(() => {
  let sites = 0
  let tracks = 0
  let others = 0
  for (const it of selection.items.value) {
    if (it.kind === 'site') sites++
    else if (it.kind === 'track') tracks++
    else others++
  }
  const parts: string[] = []
  if (sites) parts.push(`${sites} 个站点`)
  if (tracks) parts.push(`${tracks} 条路径`)
  if (others) parts.push(`${others} 个其它`)
  return parts.length ? parts.join(' + ') : '未选中'
})

async function snapshotItems(
  items: Array<{ kind: string; id: number }>,
  opts?: { mode?: 'paste' | 'delete' }
): Promise<ObjectSnapshot[]> {
  const mode = opts?.mode ?? 'paste'
  const snapshots: ObjectSnapshot[] = []
  for (const it of items) {
    try {
      const b = await reflectionApi.getBundle(it.kind as ReflectionKind, it.id)
      const raw = (b.fields ?? {}) as Record<string, unknown>
      const data: Record<string, unknown> = {}
      for (const [k, v] of Object.entries(raw)) {
        if (mode === 'delete') {
          if (DELETE_SNAPSHOT_FIELD_DENY.has(k)) continue
        } else if (!PASTE_FIELD_ALLOW.has(k)) {
          continue
        }
        if (v == null || v === '') continue
        data[k] = v
      }
      if (data.layerName != null && (data.layer == null || data.layer === '')) {
        data.layer = data.layerName
      }
      snapshots.push({ kind: it.kind, typeName: b.typeName, data })
    } catch (err) {
      const msg = err instanceof Error ? err.message : String(err)
      throw new Error(`读取对象快照失败（${it.kind}#${it.id}）：${msg}`)
    }
  }
  return snapshots
}

async function snapshotSelection(): Promise<ObjectSnapshot[]> {
  return snapshotItems(selection.items.value, { mode: 'paste' })
}

function offsetSnapshotData(data: Record<string, unknown>, dx: number, dy: number): Record<string, unknown> {
  const next = { ...data }
  delete next.id
  if (next.x != null && next.x !== '') next.x = Number(next.x) + dx
  if (next.y != null && next.y !== '') next.y = Number(next.y) + dy
  return next
}

async function copySelection(): Promise<boolean> {
  const selectedCount = selection.items.value.length
  if (!selectedCount) {
    editorClipboard.value = []
    ElMessage.warning('请先选中要复制的对象')
    return false
  }
  try {
    const snapshots = await snapshotSelection()
    if (snapshots.length !== selectedCount) {
      throw new Error(`只读取到 ${snapshots.length}/${selectedCount} 个对象快照`)
    }
    editorClipboard.value = snapshots
    ElMessage.success(`已复制 ${snapshots.length} 个对象`)
    return true
  } catch (err) {
    editorClipboard.value = []
    ElMessage.error(`复制失败：${(err as Error).message}`)
    return false
  }
}

async function pasteClipboard() {
  if (!editorClipboard.value.length) {
    ElMessage.warning('剪贴板为空，请先复制')
    return
  }
  const ops = editorClipboard.value.map((s) => ({
    action: 'create' as const,
    kind: pickCreateKind(s.kind, s.typeName),
    data: offsetSnapshotData(s.data, PASTE_OFFSET_MM, PASTE_OFFSET_MM)
  }))
  let createdIds: { kind: string; id: number }[] = []
  try {
    await history.run({
      label: `粘贴 ${ops.length} 个对象`,
      apply: async () => {
        const r = await mapEditApi.batch(ops)
        createdIds = (r.results as Array<{ ok: boolean; action: string; kind?: string; result?: { kind: string; id: number } }>)
          .filter((x) => x.ok && x.action === 'create' && x.result?.id)
          .map((x) => ({ kind: x.result!.kind || x.kind || 'site', id: x.result!.id }))
      },
      revert: async () => {
        if (!createdIds.length) return
        await mapEditApi.batch(createdIds.map((c) => ({ action: 'delete' as const, kind: c.kind, id: c.id })))
      }
    })
    ElMessage.success(`已粘贴 ${createdIds.length || ops.length} 个对象`)
  } catch (err) {
    ElMessage.error(`粘贴失败：${(err as Error).message}`)
  }
}

async function duplicateSelection() {
  const copied = await copySelection()
  if (!copied) return
  await pasteClipboard()
}

async function cutSelection() {
  if (selection.items.value.length === 0) {
    ElMessage.warning('请先选中对象')
    return
  }
  const copied = await copySelection()
  if (!copied) return
  await deleteSelection({ skipEmptyWarn: true })
}

function openBetterAddFieldDialog() {
  if (selection.items.value.length === 0) {
    ElMessage.warning('请先在地图上选中要添加字段的站点、路径或车辆（可框选多选）')
    return
  }
  betterAddFieldOpen.value = true
}

async function deleteSelection(opts?: { skipEmptyWarn?: boolean }) {
  const items = [...selection.items.value]
  if (items.length === 0) {
    if (!opts?.skipEmptyWarn) ElMessage.warning('请先选中要删除的对象')
    return
  }

  try {
    await ElMessageBox.confirm(
      `确定删除选中的 ${items.length} 个对象？此操作可通过撤销恢复。`,
      '删除确认',
      {
        type: 'warning',
        confirmButtonText: '删除',
        cancelButtonText: '取消',
        distinguishCancelAndClose: true
      }
    )
  } catch {
    return
  }

  // 删除前必须拿到完整快照，否则撤销无法可靠恢复。
  let snapshots: ObjectSnapshot[]
  try {
    snapshots = await snapshotItems(items, { mode: 'delete' })
    if (snapshots.length !== items.length) {
      throw new Error(`只读取到 ${snapshots.length}/${items.length} 个对象快照`)
    }
  } catch (err) {
    ElMessage.error(`删除已取消：${(err as Error).message}`)
    return
  }

  try {
    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 已重排）`)
        }
      }
    })
  } catch (err) {
    ElMessage.error(`删除失败：${(err as Error).message}`)
  }
}

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
}

const CAD_ALIGN_MODES = new Set<AlignMode>([
  'left', 'right', 'top', 'bottom', 'centerH', 'centerV', 'center', 'distributeH', 'distributeV'
])

async function applyAlignment(mode: AlignMode) {
  const refs = selection.items.value.filter((it) => it.kind !== 'track' && it.kind !== 'car')
  if (refs.length < 2) {
    ElMessage.warning('对齐需要选中至少 2 个站点或装饰（文本/模型）')
    return
  }

  try {
    if (CAD_ALIGN_MODES.has(mode)) {
      let previous: Array<{ kind: string; id: number; x: number; y: number }> = []
      let alignedCount = 0
      await history.run({
        label: `对齐 (${mode})`,
        apply: async () => {
          const r = await mapEditApi.cadAlign(mode, refs.map((it) => ({ kind: it.kind, id: it.id })))
          previous = r.previous ?? []
          alignedCount = r.count ?? 0
          if (!alignedCount) throw new Error('没有可对齐的站点或装饰')
        },
        revert: async () => {
          if (!previous.length) return
          await mapEditApi.batch(previous.map((t) => ({
            action: 'patch' as const,
            kind: t.kind,
            id: t.id,
            data: { x: t.x, y: t.y }
          })))
        }
      })
      ElMessage.success(`已对齐 ${alignedCount} 个对象`)
      return
    }

    const targets: AlignTarget[] = []
    for (const it of refs) {
      const b = await reflectionApi.getBundle(it.kind, it.id)
      targets.push({
        kind: it.kind,
        id: it.id,
        x: Number(b.fields?.x ?? 0),
        y: Number(b.fields?.y ?? 0)
      })
    }
    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 () => {
        await mapEditApi.batch(targets.map((t) => ({
          action: 'patch' as const,
          kind: t.kind,
          id: t.id,
          data: { x: t.x, y: t.y }
        })))
      }
    })
    ElMessage.success(`已对齐 ${targets.length} 个对象`)
  } catch (err) {
    ElMessage.error(`对齐失败：${(err as Error).message}`)
  }
}

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 {
      return
    }
    return
    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 {
      return
    }
    return
    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 {
      return
    }
    return
    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 (err) {
        const msg = err instanceof Error ? err.message : String(err)
        throw new Error(`读取对象字段失败（${t.kind}#${t.id}）：${msg}`)
      }
    }

    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} 完成`)
}

// ──────────────────────────────────────────────────────────────────────────
// 属性面板交?
// ──────────────────────────────────────────────────────────────────────────

/** 反射字段值展示：避免 object/array ?String() ?[object Object] 而「读不到」?*/
function fieldValueToDisplay(v: unknown): string {
  if (v == null) return ''
  if (typeof v === 'string') return v
  if (typeof v === 'number' || typeof v === 'boolean' || typeof v === 'bigint') return String(v)
  try {
    return JSON.stringify(v)
  } catch {
    return String(v)
  }
}

async function refreshPrimary(opts?: { silent?: boolean }) {
  if (selection.items.value.length === 0) {
    primary.value = null
    primaryFields.value = []
    primaryMethods.value = []
    primaryStatus.value = []
    return
  }
  const first = selection.items.value[0]!
  if (!opts?.silent) 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: fieldValueToDisplay(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 {
    if (!opts?.silent) primaryLoadingEnd()
  }
}

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

function patchPrimaryFieldLocal(key: string, value: string) {
  const idx = primaryFields.value.findIndex((f) => f.key === key)
  if (idx >= 0) primaryFields.value[idx] = { key, value }
  else primaryFields.value = [...primaryFields.value, { key, value }]
}

function removePrimaryFieldLocal(key: string) {
  primaryFields.value = primaryFields.value.filter((f) => f.key !== key)
}

let silentRefreshTimer: ReturnType<typeof setTimeout> | null = null
function scheduleSilentRefreshPrimary() {
  if (silentRefreshTimer) clearTimeout(silentRefreshTimer)
  silentRefreshTimer = setTimeout(() => {
    silentRefreshTimer = null
    void refreshPrimary({ silent: true })
  }, 200)
}

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]!
  const hadKey = primaryFields.value.some((f) => f.key === key)
  const oldValue = primaryFields.value.find((f) => f.key === key)?.value

  patchPrimaryFieldLocal(key, value)
  try {
    await history.run({
      label: hadKey ? `修改 ${key}` : `添加字段 ${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 {
          try { await reflectionApi.deleteField(it.kind, primary.value!.id, key) }
          catch (err) { ElMessage.warning(`回滚字段 ${key} 失败：${(err as Error).message}`) }
        }
      }
    })
  } catch (err) {
    if (oldValue !== undefined) patchPrimaryFieldLocal(key, oldValue)
    else removePrimaryFieldLocal(key)
    ElMessage.error(`保存字段失败：${(err as Error).message}`)
    return
  }
  scheduleSilentRefreshPrimary()
}

async function onDeleteField(key: string) {
  if (!primary.value) return
  const it = selection.items.value[0]!
  const oldValue = primaryFields.value.find((f) => f.key === key)?.value
  if (oldValue === undefined) return

  removePrimaryFieldLocal(key)
  try {
    await history.run({
      label: `删除字段 ${key}`,
      apply: async () => { await reflectionApi.deleteField(it.kind, primary.value!.id, key) },
      revert: async () => {
        try { await reflectionApi.setField(it.kind, primary.value!.id, key, oldValue) }
        catch (err) { ElMessage.warning(`回滚字段 ${key} 失败：${(err as Error).message}`) }
      }
    })
  } catch (err) {
    patchPrimaryFieldLocal(key, oldValue)
    ElMessage.error(`删除字段失败：${(err as Error).message}`)
    return
  }
  scheduleSilentRefreshPrimary()
}

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 parseViewportPayload(raw: Record<string, unknown>): ViewportStylePayload {
  const siteRaw = (raw.site ?? raw.Site) as Record<string, unknown> | undefined
  const trackRaw = (raw.track ?? raw.Track) as Record<string, unknown> | undefined
  const num = (o: Record<string, unknown> | undefined, camel: string, pascal: string) =>
    Number(o?.[camel] ?? o?.[pascal] ?? 0)
  const bool = (o: Record<string, unknown> | undefined, camel: string, pascal: string) =>
    Boolean(o?.[camel] ?? o?.[pascal])
  return normalizeViewportPayload({
    site: {
      drawScale: num(siteRaw, 'drawScale', 'DrawScale'),
      dotRadiusNormal: num(siteRaw, 'dotRadiusNormal', 'DotRadiusNormal'),
      dotRadiusSelected: num(siteRaw, 'dotRadiusSelected', 'DotRadiusSelected'),
      selectionRingM: num(siteRaw, 'selectionRingM', 'SelectionRingM'),
      colorNormalArgb: num(siteRaw, 'colorNormalArgb', 'ColorNormalArgb'),
      colorSelectedArgb: num(siteRaw, 'colorSelectedArgb', 'ColorSelectedArgb'),
      labelColorNormalArgb: num(siteRaw, 'labelColorNormalArgb', 'LabelColorNormalArgb'),
      labelColorSelectedArgb: num(siteRaw, 'labelColorSelectedArgb', 'LabelColorSelectedArgb')
    },
    track: {
      drawScale: num(trackRaw, 'drawScale', 'DrawScale'),
      widthNormal: num(trackRaw, 'widthNormal', 'WidthNormal'),
      widthSelected: num(trackRaw, 'widthSelected', 'WidthSelected'),
      showDirectionArrows: bool(trackRaw, 'showDirectionArrows', 'ShowDirectionArrows'),
      colorNormalArgb: num(trackRaw, 'colorNormalArgb', 'ColorNormalArgb'),
      colorSelectedArgb: num(trackRaw, 'colorSelectedArgb', 'ColorSelectedArgb')
    }
  })
}

async function loadViewportStyle() {
  viewportStyleLoading.value = true
  try {
    const raw = await reflectionApi.getViewportStyle()
    viewportStyle.value = parseViewportPayload(raw as unknown as Record<string, unknown>)
  } catch (err) {
    console.warn('[MapEditor] 加载视口样式失败：', err)
  } finally {
    viewportStyleLoading.value = false
  }
}

function onPatchViewport(patch: { site?: ViewportStyleSite; track?: ViewportStyleTrack }) {
  if (patch.site) {
    pendingViewportPatch.site = patch.site
    if (viewportStyle.value) viewportStyle.value = { ...viewportStyle.value, site: { ...patch.site } }
  }
  if (patch.track) {
    pendingViewportPatch.track = patch.track
    if (viewportStyle.value) viewportStyle.value = { ...viewportStyle.value, track: { ...patch.track } }
  }
  if (viewportPatchTimer) clearTimeout(viewportPatchTimer)
  viewportPatchTimer = setTimeout(() => {
    const body = { ...pendingViewportPatch }
    pendingViewportPatch = {}
    void flushViewportPatch(body)
  }, 350)
}

async function flushViewportPatch(patch: { site?: ViewportStyleSite; track?: ViewportStyleTrack }) {
  const seq = ++viewportPatchSeq
  viewportSyncing.value = true
  try {
    const body: { site?: ViewportStyleSite; track?: ViewportStyleTrack } = {}
    if (patch.site) body.site = patch.site
    if (patch.track) body.track = patch.track
    const raw = await reflectionApi.patchViewportStyle(body)
    if (seq !== viewportPatchSeq) return
    viewportStyle.value = parseViewportPayload(raw as unknown as Record<string, unknown>)
  } catch (err) {
    ElMessage.error(`视口样式更新失败：${(err as Error).message}`)
    await loadViewportStyle()
  } finally {
    if (seq === viewportPatchSeq) viewportSyncing.value = false
  }
}

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
}

// 恢复「AI 助手」面板的开关状态与宽度（来自上次会话的 localStorage）?
function restoreAiPanelPrefs() {
  try {
    aiAssistantOpen.value = localStorage.getItem(AI_PANEL_OPEN_KEY) === '1'
    const w = Number(localStorage.getItem(AI_PANEL_WIDTH_KEY))
    if (Number.isFinite(w) && w >= 300 && w <= 720) aiPanelWidth.value = w
  } catch { /* localStorage 不可用则用默认值 */ }
}

watch(aiAssistantOpen, (v) => {
  try { localStorage.setItem(AI_PANEL_OPEN_KEY, v ? '1' : '0') } catch { /* ignore */ }
})
watch(aiPanelWidth, (v) => {
  try { localStorage.setItem(AI_PANEL_WIDTH_KEY, String(Math.round(v))) } catch { /* ignore */ }
})

// 右侧「AI 助手」入口：展开前刷新一?AI 配置状态，保证「未配置」提示与后端一致?
async function toggleAiAssistant() {
  if (!aiAssistantOpen.value) {
    try {
      const cfg = await aiConfigApi.get()
      aiConfigured.value = !!cfg.configured
    } catch {
      aiConfigured.value = false
    }
  }
  aiAssistantOpen.value = !aiAssistantOpen.value
}

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 saveMapAndLeave() {
  const mode = await promptSaveMode()
  if (mode === 'cancel') return

  let originalName = editingMapName.value.trim()
  if (!originalName) {
    originalName = await resolveCurrentMapName()
    if (originalName) editingMapName.value = originalName
  }

  let name: string
  let overwrite = false

  if (mode === 'replace') {
    if (!originalName) {
      ElMessage.warning('未找到可覆盖的原地图，请选择「保存成新项目」')
      return
    }
    name = originalName
    overwrite = true
  } else {
    const picked = await promptNewSaveName('保存成新地图', originalName)
    if (!picked) return
    name = picked
  }

  saving.value = true
  try {
    let outcome = await mapsApi.save(name, overwrite)

    if (!outcome.ok && outcome.conflict && mode === 'new') {
      try {
        await ElMessageBox.confirm(
          `服务器已存在地图「${name}」，是否覆盖该文件？`,
          '覆盖确认',
          { type: 'warning', confirmButtonText: '覆盖', cancelButtonText: '取消' }
        )
      } catch {
        return
      }
      outcome = await mapsApi.save(name, true)
    }

    if (!outcome.ok) {
      ElMessage.error(`保存失败：${outcome.message}`)
      return
    }

    if (mode === 'replace') {
      ElMessage.success(`已覆盖保存地图「${name}」`)
    } else {
      editingMapName.value = outcome.data.name
      ElMessage.success(`新地图「${outcome.data.name}」已保存到服务器`)
    }
    await router.push({ path: '/admin/maps' })
  } catch (err) {
    ElMessage.error(`保存失败：${(err as Error).message}`)
  } finally {
    saving.value = false
  }
}

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

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) {
    if (fileUploadKind.value === 'image' && editTool.activeTool.value === 'draw.image') {
      editTool.setTool('select.single')
    }
    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') {
      pendingImagePath.value = r.savedPath
      editTool.setTool('draw.image')
      await startPickLoop()
    } else {
      ElMessage.info('插入模型功能暂缓（Simple3 侧未完整实现）')
    }
  } catch (err) {
    ElMessage.error(`上传失败：${(err as Error).message}`)
    if (editTool.activeTool.value === 'draw.image') editTool.setTool('select.single')
  } 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({
  onWorkspaceShortcut: (e) => { void handleEditorShortcut(e.action) },
  onAlarm: () => { /* 编辑器不展示报警，由 Monitor 页订阅 */ },
  onObjectCreated: () => refreshAfterMutation(),
  onObjectDeleted: () => refreshAfterMutation(),
  onObjectPatched: () => refreshAfterMutation(),
  onObjectBatchChanged: () => refreshAfterMutation(),
  onViewportStyleUpdated: () => { void loadViewportStyle() },
  onSelectionDetail: (e) => {
    if (!Array.isArray(e.names)) return
    const incoming = parseSelectionNames(e.names)
    const seq = ++selectionBroadcastSeq
    if (incoming.length > 0) {
      applySelectionItems(incoming)
      return
    }
    void resolveEmptySelectionBroadcast(seq)
  }
})

function refreshAfterMutation() {
  scheduleSilentRefreshPrimary()
}

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

watch(() => makeSelectionKey(selection.items.value), (key) => {
  refreshPrimary()
  lastSyncedSelectionKey.value = key
})

// ──────────────────────────────────────────────────────────────────────────
// 键盘快捷键（顶栏/侧栏聚焦时走 document；画?iframe 聚焦时走 SSE workspace-shortcut?
// ──────────────────────────────────────────────────────────────────────────

function isEditableShortcutTarget(target: EventTarget | null): boolean {
  if (!(target instanceof HTMLElement)) return false
  const tag = target.tagName
  if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true
  if (target.isContentEditable) return true
  return !!target.closest('.el-input, .el-textarea, .el-select, [contenteditable="true"]')
}

async function handleEditorShortcut(
  action: 'undo' | 'redo' | 'delete' | 'copy' | 'paste' | 'duplicate'
) {
  if (action === 'undo') {
    if (!history.canUndo.value) return
    await history.undo()
  } else if (action === 'redo') {
    if (!history.canRedo.value) return
    await history.redo()
  } else if (action === 'copy') {
    await copySelection()
  } else if (action === 'paste') {
    await pasteClipboard()
  } else if (action === 'duplicate') {
    await duplicateSelection()
  } else {
    await deleteSelection()
  }
}

function onKeyDown(ev: KeyboardEvent) {
  if (isEditableShortcutTarget(ev.target)) return

  const meta = ev.ctrlKey || ev.metaKey
  if (meta && ev.key.toLowerCase() === 'z' && !ev.shiftKey) {
    ev.preventDefault()
    void handleEditorShortcut('undo')
  } else if (meta && (ev.key.toLowerCase() === 'y' || (ev.key.toLowerCase() === 'z' && ev.shiftKey))) {
    ev.preventDefault()
    void handleEditorShortcut('redo')
  } else if (meta && ev.key.toLowerCase() === 'c') {
    ev.preventDefault()
    void handleEditorShortcut('copy')
  } else if (meta && ev.key.toLowerCase() === 'v') {
    ev.preventDefault()
    void handleEditorShortcut('paste')
  } else if (meta && ev.key.toLowerCase() === 'd') {
    ev.preventDefault()
    void handleEditorShortcut('duplicate')
  } else if (meta && ev.key.toLowerCase() === 'i' && !ev.altKey && !ev.shiftKey) {
    ev.preventDefault()
    openBetterAddFieldDialog()
  } else if (ev.key === 'Delete') {
    ev.preventDefault()
    void handleEditorShortcut('delete')
  }
}

function onWorkspaceShortcutEvent(ev: Event) {
  const action = (ev as CustomEvent<{ action?: string }>).detail?.action
  if (
    action === 'undo'
    || action === 'redo'
    || action === 'delete'
    || action === 'copy'
    || action === 'paste'
    || action === 'duplicate'
  ) {
    void handleEditorShortcut(action)
  }
}

/**
 * 从后端拉当前?viewFilter 真实状态，把本?reactive 覆盖?
 * Simple3 ?source of truth：避免「前端勾着、Simple3 真实开关却没改」的偏差
 * （比如另一?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)
    const tm = snap.selectFilter?.trackMode
    if (tm === 'straight' || tm === 'curve' || tm === 'all') trackSelectMode.value = tm
    persistViewFilter()
  } catch (err) {
    console.warn('[MapEditor] 拉后端 viewFilter 失败，沿用本地缓存：', err)
  }
}

onMounted(async () => {
  document.addEventListener('keydown', onKeyDown, true)
  window.addEventListener('workspace-shortcut', onWorkspaceShortcutEvent as EventListener)
  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 */ }
  }
  restoreAiPanelPrefs()
  loadViewFilter()
  await syncViewFilterFromBackend()
  await loadViewportStyle()
  await loadFromRouteQuery()
})

/**
 * 从地图管理页跳转进来时的初始加载?
 * - ?map=<name>：加载该地图到场景供编辑，并记录原名（保存默认沿?+ 替换确认）；
 * - ?new=1：新建地图，仅清空「正在编辑的地图名」，保存时按时间默认命名?
 */
async function loadFromRouteQuery() {
  const mapName = typeof route.query.map === 'string' ? route.query.map : ''
  const isNew = route.query.new === '1'
  if (mapName) {
    try {
      const r = await mapsApi.open(mapName)
      editingMapName.value = r.name
      selection.clear()
      primary.value = null
      ElMessage.success(`已载入地图「${r.name}」：${r.sites} 站点 / ${r.tracks} 路径`)
    } catch (err) {
      ElMessage.error(`载入地图失败：${(err as Error).message}`)
    }
  } else if (isNew) {
    editingMapName.value = ''
  } else {
    const current = await resolveCurrentMapName()
    if (current) editingMapName.value = current
  }
}

onUnmounted(() => {
  document.removeEventListener('keydown', onKeyDown, true)
  window.removeEventListener('workspace-shortcut', onWorkspaceShortcutEvent as EventListener)
})
</script>

<style scoped>
.map-editor-page {
  height: calc(100vh - 56px);
  display: flex; flex-direction: column;
  /* 画布区保持深色；色相跟当前主题侧?(--me-chrome-*) */
  background: radial-gradient(
    ellipse at 50% 40%,
    var(--me-chrome-1) 0%,
    var(--me-chrome-2) 52%,
    #080610 100%
  );
  color: var(--me-text);
}
.me-body {
  flex: 1;
  display: flex;
  min-height: 0;
  position: relative;
}

/* AI 助手入口：关闭贴页面最右侧；打开?AI 面板左沿?*/
.ai-assistant-fab {
  position: absolute;
  top: 50%;
  right: 0;
  transform: translateY(-50%);
  z-index: 40;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 5px;
  width: 42px;
  padding: 14px 0;
  border: none;
  border-radius: 12px 0 0 12px;
  cursor: pointer;
  color: var(--ai-fab-fg, #fff);
  background: var(--ai-fab-bg, var(--mg-primary));
  box-shadow: -4px 0 16px color-mix(in srgb, var(--mg-primary) 22%, transparent), var(--ai-fab-glow, none);
  transition: right 0.26s cubic-bezier(0.25, 0.8, 0.25, 1), filter 0.15s ease, box-shadow 0.15s ease;
}
.ai-assistant-fab:hover {
  filter: brightness(1.06);
}
.ai-assistant-fab .fab-glyph {
  font-size: 18px;
  line-height: 1;
}
.ai-assistant-fab .fab-text {
  font-size: 12px;
  font-weight: 700;
  line-height: 1.18;
  letter-spacing: 1px;
  text-align: center;
}

.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>
