<script setup lang="ts">
import {
  computed,
  onMounted,
  onUnmounted,
  ref,
  shallowRef,
  watch,
} from 'vue'
import DemandPathPanel from './DemandPathPanel.vue'
import DemandGradeListPanel from './DemandGradeListPanel.vue'
import type { DemandGradeListCard } from './DemandGradeListPanel.vue'
import type { CategoryNode, WeightDimKey } from '../types/category'
import {
  COLUMN_WIDTH,
  FULL_TREE_TAB,
  HEAT_DIM_TABS,
  activeBaseDepth,
  collectDimScale,
  formatNodeDimScore,
  layoutIcicle,
  mapHeatFill,
  mapHeatText,
  nodeHeatT,
  nodeStructureFill,
  nodeTextColor,
  prepareTree,
  relativeMaxDepth,
  viewRoots,
  type HeatTabKey,
  type IcicleRect,
  type PreparedNode,
} from '../types/heatTree'
import type { DemandGradeItem, DemandsByCategory } from '../types/demand'

/** Min cell height in leftmost two columns so labels stay readable. */
const MIN_READABLE_HEIGHT = 18
/** Cap when focused/view roots have no children. */
const MAX_LEAF_BLOCK_HEIGHT = 64
/**
 * Max content height when drilling down.
 * Prefer growing height (not viewScale) so column width stays 200px.
 * Canvas itself stays viewport-sized; this only sizes the scroll area.
 */
const MAX_CANVAS_HEIGHT = 24000
/** Padding around content wrap. */
const CONTENT_PAD = 8

/**
 * Smallest leafCount among nodes in the leftmost two columns
 * (view roots + their direct children).
 */
function minLeafInLeftTwoColumns(roots: PreparedNode[]): number {
  let minLeaf = Infinity
  for (const root of roots) {
    minLeaf = Math.min(minLeaf, root.leafCount)
    for (const child of root.children) {
      minLeaf = Math.min(minLeaf, child.leafCount)
    }
  }
  return Number.isFinite(minLeaf) && minLeaf > 0 ? minLeaf : 1
}

const props = defineProps<{
  nodes: CategoryNode[]
  bizDt?: string | null
  demandsByCategory?: DemandsByCategory
}>()

const canvasRef = ref<HTMLCanvasElement | null>(null)
const viewportRef = ref<HTMLElement | null>(null)
const wrapRef = ref<HTMLElement | null>(null)

const prepared = shallowRef(prepareTree([]))
const activeTab = ref<HeatTabKey>('total_score')
const startDepth = ref(0)
const focus = ref<PreparedNode | null>(null)
const selectedNode = ref<PreparedNode | null>(null)

/** Vertical zoom only — column width stays fixed at COLUMN_WIDTH. */
const viewScaleY = ref(1)
const viewY = ref(0)
const isDragging = ref(false)

const tooltipVisible = ref(false)
const tooltipX = ref(0)
const tooltipY = ref(0)
const tooltipNode = ref<PreparedNode | null>(null)

const inspectOpen = ref(false)
const inspectNode = ref<PreparedNode | null>(null)

const PATH_DOCK_HEIGHT_KEY = 'icicle-path-dock-height'
const PATH_DOCK_MIN = 120
const PATH_DOCK_MAX_RATIO = 0.75

function loadPathDockHeight(): number {
  const raw = localStorage.getItem(PATH_DOCK_HEIGHT_KEY)
  const n = raw ? Number(raw) : NaN
  return Number.isFinite(n) && n >= PATH_DOCK_MIN ? n : 240
}

const pathDockHeight = ref(loadPathDockHeight())
const pathDockResizing = ref(false)

const DEMAND_LIST_WIDTH_KEY = 'icicle-demand-list-width'
const DEMAND_LIST_MIN = 300
const DEMAND_LIST_DEFAULT = 390

function loadDemandListWidth(): number {
  const raw = localStorage.getItem(DEMAND_LIST_WIDTH_KEY)
  const n = raw ? Number(raw) : NaN
  return Number.isFinite(n) && n >= DEMAND_LIST_MIN ? n : DEMAND_LIST_DEFAULT
}

const demandListWidth = ref(loadDemandListWidth())
const demandListResizing = ref(false)

function demandListMaxWidth(): number {
  return Math.max(DEMAND_LIST_MIN, Math.floor(window.innerWidth * 0.72))
}

function onDemandListResizeStart(event: PointerEvent) {
  event.preventDefault()
  demandListResizing.value = true
  const startX = event.clientX
  const startWidth = demandListWidth.value

  const onMove = (e: PointerEvent) => {
    const delta = startX - e.clientX
    demandListWidth.value = Math.max(
      DEMAND_LIST_MIN,
      Math.min(demandListMaxWidth(), startWidth + delta),
    )
  }

  const onUp = () => {
    demandListResizing.value = false
    localStorage.setItem(DEMAND_LIST_WIDTH_KEY, String(demandListWidth.value))
    window.removeEventListener('pointermove', onMove)
    window.removeEventListener('pointerup', onUp)
    window.removeEventListener('pointercancel', onUp)
  }

  window.addEventListener('pointermove', onMove)
  window.addEventListener('pointerup', onUp)
  window.addEventListener('pointercancel', onUp)
}

function pathDockMaxHeight(): number {
  return Math.floor(window.innerHeight * PATH_DOCK_MAX_RATIO)
}

function onPathDockResizeStart(event: PointerEvent) {
  event.preventDefault()
  pathDockResizing.value = true
  const startY = event.clientY
  const startHeight = pathDockHeight.value

  const onMove = (e: PointerEvent) => {
    const delta = startY - e.clientY
    pathDockHeight.value = Math.max(
      PATH_DOCK_MIN,
      Math.min(pathDockMaxHeight(), startHeight + delta),
    )
  }

  const onUp = () => {
    pathDockResizing.value = false
    localStorage.setItem(PATH_DOCK_HEIGHT_KEY, String(pathDockHeight.value))
    window.removeEventListener('pointermove', onMove)
    window.removeEventListener('pointerup', onUp)
    window.removeEventListener('pointercancel', onUp)
  }

  window.addEventListener('pointermove', onMove)
  window.addEventListener('pointerup', onUp)
  window.addEventListener('pointercancel', onUp)
}

const inspectItems = computed<DemandGradeItem[]>(() => {
  if (!inspectNode.value) return []
  const categoryId = inspectNode.value.id
  const all = props.demandsByCategory?.[categoryId] ?? []

  if (
    listSelectedDemandId.value != null &&
    listSelectedCategoryId.value === categoryId
  ) {
    const picked = all.find((item) => item.id === listSelectedDemandId.value)
    if (picked) return [picked]
  }
  return all
})

const inspectDemandName = computed(() => {
  if (listSelectedDemandId.value == null) return null
  const card = demandCards.value.find((c) => c.id === listSelectedDemandId.value)
  return card?.demand_name ?? null
})

const listSelectedDemandId = ref<number | null>(null)
const listSelectedCategoryId = ref<number | null>(null)

const preparedNodeById = computed(() => {
  const m = new Map<number, PreparedNode>()
  for (const n of prepared.value.flat) m.set(n.id, n)
  return m
})

const demandCards = computed<DemandGradeListCard[]>(() => {
  // props.demandsByCategory: category_id -> DemandGradeItem[]
  const metaById = new Map<
    number,
    Omit<DemandGradeListCard, 'categoryOptions'> & { _categoryIds: Set<number> }
  >()

  for (const [categoryIdRaw, items] of Object.entries(props.demandsByCategory ?? {})) {
    const categoryId = Number(categoryIdRaw)
    for (const item of items) {
      if (!item || item.id == null) continue
      const entry = metaById.get(item.id)
      if (!entry) {
        metaById.set(item.id, {
          id: item.id,
          demand_name: item.demand_name,
          grade: item.grade,
          score: item.score,
          reason: item.reason,
          strategies: item.strategies,
          biz_dt: item.biz_dt,
          _categoryIds: new Set<number>(),
        })
      }
      metaById.get(item.id)!._categoryIds.add(categoryId)
    }
  }

  const result: DemandGradeListCard[] = []
  for (const entry of metaById.values()) {
    const categoryIds = Array.from(entry._categoryIds).sort((a, b) => a - b)
    const categoryOptions = categoryIds.map((cid) => ({
      categoryId: cid,
      categoryName: preparedNodeById.value.get(cid)?.name ?? '(未命名)',
    }))
    result.push({
      id: entry.id,
      demand_name: entry.demand_name,
      grade: entry.grade,
      score: entry.score,
      reason: entry.reason,
      strategies: entry.strategies,
      biz_dt: entry.biz_dt,
      categoryOptions,
    })
  }
  return result
})

function collectSubtreeCategoryIds(node: PreparedNode): Set<number> {
  const ids = new Set<number>()
  const walk = (n: PreparedNode) => {
    ids.add(n.id)
    n.children.forEach(walk)
  }
  walk(node)
  return ids
}

/** Right panel: all demands globally, or subtree of focused category only. */
const visibleDemandCards = computed<DemandGradeListCard[]>(() => {
  const node = focus.value
  if (!node) return demandCards.value

  const allowed = collectSubtreeCategoryIds(node)
  return demandCards.value
    .filter((card) => card.categoryOptions.some((opt) => allowed.has(opt.categoryId)))
    .map((card) => ({
      ...card,
      categoryOptions: card.categoryOptions.filter((opt) => allowed.has(opt.categoryId)),
    }))
})

function clearDemandListSelection() {
  listSelectedDemandId.value = null
  listSelectedCategoryId.value = null
}

const inspectHighlightDemandId = computed<number | null>(() => {
  if (!inspectNode.value) return null
  if (listSelectedCategoryId.value !== inspectNode.value.id) return null
  return listSelectedDemandId.value
})

function selectCategoryNodeById(categoryId: number) {
  const node = preparedNodeById.value.get(categoryId)
  if (!node) return
  // When selection originates from demand list, keep list selection state.
  selectNode(node, true, 'demandList')
}

function onSelectDemandFromList(payload: { demandId: number; defaultCategoryId: number | null }) {
  listSelectedDemandId.value = payload.demandId
  listSelectedCategoryId.value = payload.defaultCategoryId
  if (payload.defaultCategoryId != null) {
    selectCategoryNodeById(payload.defaultCategoryId)
  }
}

function onSelectDemandCategoryFromList(payload: { demandId: number; categoryId: number }) {
  listSelectedDemandId.value = payload.demandId
  listSelectedCategoryId.value = payload.categoryId
  selectCategoryNodeById(payload.categoryId)
}

function nodeHasDemands(node: PreparedNode): boolean {
  return (props.demandsByCategory?.[node.id]?.length ?? 0) > 0
}

/** Plain arrays — avoid Vue reactivity on thousands of rects. */
let baseRects: IcicleRect[] = []
let drawRects: IcicleRect[] = []
let layoutCacheKey = ''
let fillCache = new Map<number, string>()
let textCache = new Map<number, string>()

let resizeObserver: ResizeObserver | null = null
let dpr = 1
let drawRaf = 0
let scrollPending = false
/** After navigation: reset transform so width stays 200px/col. */
let needResetTransform = false
let applyingExtent = false
let lastExtentKey = ''

const drag = {
  active: false,
  moved: false,
  x: 0,
  y: 0,
  pointerId: -1,
}

const maxDepth = computed(() => prepared.value.maxDepth)
const baseDepth = computed(() => activeBaseDepth(startDepth.value, focus.value))
const currentViewRoots = computed(() =>
  viewRoots(prepared.value.roots, focus.value, startDepth.value),
)

const isFullTree = computed(() => activeTab.value === FULL_TREE_TAB)
const activeDim = computed<WeightDimKey | null>(() =>
  isFullTree.value ? null : (activeTab.value as WeightDimKey),
)

const weightScale = computed(() => {
  if (!activeDim.value) return [] as number[]
  return collectDimScale(prepared.value.flat, activeDim.value)
})

const treeTabs = computed(() => [
  { key: FULL_TREE_TAB as HeatTabKey, label: '全局树' },
  ...HEAT_DIM_TABS.map((d) => ({ key: d.key as HeatTabKey, label: d.label })),
])

const activeDimLabel = computed(() => {
  if (!activeDim.value) return null
  return HEAT_DIM_TABS.find((d) => d.key === activeDim.value)?.label ?? activeDim.value
})

const mapContext = computed(() => {
  const total = prepared.value.flat.length
  const heatHint = activeDim.value
    ? ` · ${activeDimLabel.value}色阶 · 无数据为白`
    : ''
  if (focus.value) {
    return `${focus.value.path.join(' / ')} · 当前分支 · 共 ${total} 个节点${heatHint}`
  }
  if (startDepth.value > 0) {
    return `从第 ${startDepth.value} 级开始 · ${currentViewRoots.value.length} 个可视根 · 共 ${total} 个节点${heatHint}`
  }
  return `全局视图 · 横向为层级，纵向为分支规模 · ${total} 个节点${heatHint}`
})

const breadcrumbNodes = computed(() => {
  const pathNodes: PreparedNode[] = []
  let current = selectedNode.value || focus.value
  while (current) {
    pathNodes.unshift(current)
    current = current.parent
  }
  return pathNodes
})

function scheduleDraw() {
  cancelAnimationFrame(drawRaf)
  drawRaf = requestAnimationFrame(() => draw())
}

watch(
  () => props.nodes,
  (nodes) => {
    prepared.value = prepareTree(nodes)
    invalidatePaintCache()
    resetGlobalView()
  },
  { immediate: true },
)

watch(activeTab, () => {
  invalidatePaintCache()
  scheduleDraw()
})

watch(focus, () => {
  if (listSelectedDemandId.value == null) return
  const stillVisible = visibleDemandCards.value.some((c) => c.id === listSelectedDemandId.value)
  if (!stillVisible) clearDemandListSelection()
})

function invalidatePaintCache() {
  layoutCacheKey = ''
  fillCache = new Map()
  textCache = new Map()
}

function cellFill(node: PreparedNode): string {
  const cached = fillCache.get(node.id)
  if (cached) return cached
  const color = !activeDim.value
    ? nodeStructureFill(node)
    : mapHeatFill(nodeHeatT(node, activeDim.value, weightScale.value))
  fillCache.set(node.id, color)
  return color
}

function cellText(node: PreparedNode): string {
  const cached = textCache.get(node.id)
  if (cached) return cached
  const color = !activeDim.value
    ? nodeTextColor()
    : mapHeatText(nodeHeatT(node, activeDim.value, weightScale.value))
  textCache.set(node.id, color)
  return color
}

function onTabClick(key: HeatTabKey) {
  if (activeTab.value === key) return
  activeTab.value = key
}

function resetViewTransform() {
  viewScaleY.value = 1
  viewY.value = 0
}

function resetGlobalView() {
  startDepth.value = 0
  focus.value = null
  selectedNode.value = null
  clearDemandListSelection()
  closeInspect()
  hideTooltip()
  scrollPending = false
  needResetTransform = false
  lastExtentKey = ''
  resetViewTransform()
  if (viewportRef.value) {
    viewportRef.value.scrollTop = 0
    viewportRef.value.scrollLeft = 0
  }
  scheduleDraw()
}

function selectNode(
  node: PreparedNode,
  withFocus = false,
  source: 'canvas' | 'demandList' = 'canvas',
) {
  if (source === 'canvas') clearDemandListSelection()
  selectedNode.value = node
  if (withFocus) {
    focus.value = node
    startDepth.value = node.path.length - 1
  }
  hideTooltip()
  scrollPending = false
  needResetTransform = true
  lastExtentKey = ''
  layoutCacheKey = ''
  resetViewTransform()
  if (viewportRef.value) {
    viewportRef.value.scrollTop = 0
    viewportRef.value.scrollLeft = 0
  }
  // Auto-open path panel when this category has demand words (same as 🔍 nodes).
  const items = props.demandsByCategory?.[node.id] ?? []
  if (items.length) {
    inspectNode.value = node
    inspectOpen.value = true
  } else {
    closeInspect()
  }
  scheduleDraw()
}

function closeInspect() {
  inspectOpen.value = false
  inspectNode.value = null
}

function onDepthClick(depth: number) {
  if (depth === 0) {
    resetGlobalView()
    return
  }
  focus.value = null
  selectedNode.value = null
  clearDemandListSelection()
  closeInspect()
  startDepth.value = depth
  hideTooltip()
  scrollPending = false
  needResetTransform = true
  lastExtentKey = ''
  layoutCacheKey = ''
  resetViewTransform()
  if (viewportRef.value) {
    viewportRef.value.scrollTop = 0
    viewportRef.value.scrollLeft = 0
  }
  scheduleDraw()
}

function onBreadcrumbClick(node: PreparedNode) {
  selectNode(node, true)
}

function computeExtent(viewportHeight: number): { width: number; height: number } {
  const roots = currentViewRoots.value
  const depth = baseDepth.value
  const columns = Math.max(1, relativeMaxDepth(roots, depth) + 1)
  // Width is always N × 200px — never scaled by auto-fit.
  const width = columns * COLUMN_WIDTH + CONTENT_PAD

  if (!roots.length) {
    return { width, height: Math.max(320, viewportHeight) }
  }

  const totalLeaves = roots.reduce((sum, node) => sum + node.leafCount, 0)
  const scoped = startDepth.value > 0 || Boolean(focus.value)
  const hasChildren = roots.some((node) => node.children.length > 0)

  // Overview (root): fill viewport height, fixed column widths.
  if (!scoped) {
    return {
      width,
      height: Math.max(320, viewportHeight),
    }
  }

  // Leaf-only drill-down: capped block height (already readable).
  if (!hasChildren || totalLeaves <= 0) {
    return {
      width,
      height: Math.min(
        MAX_CANVAS_HEIGHT,
        Math.max(MAX_LEAF_BLOCK_HEIGHT, roots.length * MAX_LEAF_BLOCK_HEIGHT),
      ),
    }
  }

  // Grow height only so leftmost-two-column thinnest cell >= MIN_READABLE_HEIGHT.
  // Do not use viewScale here — that would also widen the 200px columns.
  const minLeaf = minLeafInLeftTwoColumns(roots)
  const idealHeight = (MIN_READABLE_HEIGHT * totalLeaves) / minLeaf
  const height = Math.max(
    MIN_READABLE_HEIGHT,
    Math.min(MAX_CANVAS_HEIGHT, idealHeight),
  )

  return { width, height }
}

function applyWrapSize(width: number, height: number) {
  const wrap = wrapRef.value
  if (!wrap) return
  const scaledHeight = Math.round(height * viewScaleY.value)
  const key = `${startDepth.value}:${focus.value?.id ?? 'none'}:${Math.round(width)}x${scaledHeight}:${viewScaleY.value}`
  if (
    key === lastExtentKey &&
    wrap.style.height === `${scaledHeight}px` &&
    wrap.style.width === `${width}px`
  ) {
    return
  }
  lastExtentKey = key
  applyingExtent = true
  wrap.style.width = `${width}px`
  wrap.style.height = `${scaledHeight}px`
  queueMicrotask(() => {
    applyingExtent = false
  })
}

function fitView() {
  needResetTransform = true
  lastExtentKey = ''
  layoutCacheKey = ''
  resetViewTransform()
  if (viewportRef.value) {
    viewportRef.value.scrollTop = 0
    viewportRef.value.scrollLeft = 0
  }
  scheduleDraw()
}

function zoomAt(factor: number, _screenX: number, screenY: number) {
  const oldScaleY = viewScaleY.value
  const nextScaleY = Math.max(0.5, Math.min(18, oldScaleY * factor))
  const worldY = (screenY - viewY.value) / oldScaleY
  viewScaleY.value = nextScaleY
  viewY.value = screenY - worldY * nextScaleY
  lastExtentKey = ''
  scheduleDraw()
}

function ensureLayout(extentHeight: number, depth: number) {
  const roots = currentViewRoots.value
  const key = [
    focus.value?.id ?? 'g',
    startDepth.value,
    depth,
    Math.round(extentHeight),
    roots.length,
  ].join(':')
  if (key === layoutCacheKey && baseRects.length) return
  layoutCacheKey = key
  baseRects = layoutIcicle(roots, depth, COLUMN_WIDTH, extentHeight)
}

function draw() {
  const canvas = canvasRef.value
  const viewport = viewportRef.value
  if (!canvas || !viewport || !prepared.value.flat.length) return

  const vw = viewport.clientWidth
  const vh = viewport.clientHeight
  if (vw <= 0 || vh <= 0) return

  const extent = computeExtent(vh)
  applyWrapSize(extent.width, extent.height)

  if (needResetTransform) {
    needResetTransform = false
    viewScaleY.value = 1
    viewY.value = 0
  }

  // Canvas stays viewport-sized (sticky). Never allocate a multi-thousand-px bitmap.
  canvas.style.width = `${vw}px`
  canvas.style.height = `${vh}px`
  dpr = Math.min(window.devicePixelRatio || 1, 2)
  const bw = Math.round(vw * dpr)
  const bh = Math.round(vh * dpr)
  if (canvas.width !== bw || canvas.height !== bh) {
    canvas.width = bw
    canvas.height = bh
  }

  const ctx = canvas.getContext('2d', { alpha: false })
  if (!ctx) return
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
  ctx.fillStyle = '#f8fafc'
  ctx.fillRect(0, 0, vw, vh)

  const depth = baseDepth.value
  ensureLayout(extent.height, depth)

  const scaleY = viewScaleY.value
  const scrollLeft = viewport.scrollLeft
  const scrollTop = viewport.scrollTop
  const ox = -scrollLeft
  const oy = viewY.value - scrollTop

  const pad = 2
  const vx0 = -pad
  const vy0 = -pad
  const vx1 = vw + pad
  const vy1 = vh + pad
  const selected = selectedNode.value
  const strokeColor = activeDim.value ? 'rgba(15,23,42,.08)' : 'rgba(255,255,255,.88)'

  drawRects = []

  for (const item of baseRects) {
    const x = item.x + ox
    const y = item.y * scaleY + oy
    const width = item.width
    const height = item.height * scaleY

    if (x + width < vx0 || y + height < vy0 || x > vx1 || y > vy1) continue
    if (height < 0.5 || width < 0.5) continue

    drawRects.push({ node: item.node, x, y, width, height })

    ctx.fillStyle = cellFill(item.node)
    ctx.fillRect(x, y, width, height)

    if (height >= 3) {
      ctx.strokeStyle = strokeColor
      ctx.lineWidth = 0.8
      ctx.strokeRect(x, y, width, height)
    }

    if (selected === item.node) {
      ctx.strokeStyle = '#7c3aed'
      ctx.lineWidth = 3
      ctx.strokeRect(
        x + 1.5,
        y + 1.5,
        Math.max(0, width - 3),
        Math.max(0, height - 3),
      )
    }

    if (height >= 14 && width >= 40) {
      ctx.fillStyle = cellText(item.node)
      const fontSize = height >= 26 ? 12 : height >= 16 ? 10 : 9
      ctx.font = `${fontSize}px "PingFang SC", "Microsoft YaHei", sans-serif`
      ctx.textBaseline = 'middle'
      const maxW = width - 8
      const hasDemand = nodeHasDemands(item.node)
      const suffix = hasDemand ? ' 🔍' : ''
      let text = `${item.node.name}${suffix}`
      if (ctx.measureText(text).width > maxW) {
        const approx = Math.max(1, Math.floor(maxW / (fontSize * 0.95)) - (hasDemand ? 2 : 0))
        text = `${item.node.name.slice(0, Math.max(1, approx))}…${suffix}`
      }
      ctx.fillText(text, x + 5, y + Math.min(height / 2, fontSize + 4))
    }
  }

  if (scrollPending) {
    scrollPending = false
    scrollSelectedIntoView()
  }
}

function nodeAt(clientX: number, clientY: number): PreparedNode | null {
  const canvas = canvasRef.value
  const viewport = viewportRef.value
  if (!canvas || !viewport || !baseRects.length) return null
  const rect = canvas.getBoundingClientRect()
  const scaleY = viewScaleY.value || 1
  const contentX = clientX - rect.left + viewport.scrollLeft
  const contentY = (clientY - rect.top - viewY.value + viewport.scrollTop) / scaleY

  let match: IcicleRect | null = null
  for (let i = baseRects.length - 1; i >= 0; i -= 1) {
    const item = baseRects[i]
    if (
      contentX >= item.x &&
      contentX <= item.x + item.width &&
      contentY >= item.y &&
      contentY <= item.y + item.height
    ) {
      if (!match || item.node.path.length > match.node.path.length) match = item
    }
  }
  return match?.node ?? null
}

function scrollSelectedIntoView() {
  if (!selectedNode.value) return
  const item = baseRects.find((rect) => rect.node === selectedNode.value)
  const viewport = viewportRef.value
  if (!item || !viewport) return

  viewport.scrollTop = Math.max(0, item.y * viewScaleY.value)
  viewport.scrollLeft = Math.max(0, item.x)
  viewY.value = 0
  scheduleDraw()
}

function hideTooltip() {
  tooltipVisible.value = false
  tooltipNode.value = null
}

function updateTooltip(event: PointerEvent, node: PreparedNode) {
  const viewport = viewportRef.value
  if (!viewport) return
  const rect = viewport.getBoundingClientRect()
  tooltipNode.value = node
  tooltipVisible.value = true
  tooltipX.value = Math.min(rect.width - 260, event.clientX - rect.left + 14)
  tooltipY.value = Math.min(rect.height - 120, event.clientY - rect.top + 14)
}

function onPointerDown(event: PointerEvent) {
  if (event.button !== 0) return
  const canvas = canvasRef.value
  if (!canvas) return
  canvas.setPointerCapture(event.pointerId)
  drag.active = true
  drag.moved = false
  drag.x = event.clientX
  drag.y = event.clientY
  drag.pointerId = event.pointerId
  isDragging.value = true
  hideTooltip()
}

function onPointerMove(event: PointerEvent) {
  if (drag.active && drag.pointerId === event.pointerId) {
    const dx = event.clientX - drag.x
    const dy = event.clientY - drag.y
    if (Math.abs(dx) + Math.abs(dy) > 3) drag.moved = true

    if (viewportRef.value) {
      viewportRef.value.scrollLeft -= dx
      if (viewScaleY.value === 1) {
        viewportRef.value.scrollTop -= dy
      } else {
        viewY.value += dy
        scheduleDraw()
      }
    }
    drag.x = event.clientX
    drag.y = event.clientY
    return
  }

  if (drag.active) return

  const node = nodeAt(event.clientX, event.clientY)
  if (!node) {
    if (tooltipVisible.value) hideTooltip()
    return
  }

  // Tooltip only — no canvas redraw on hover (major lag source).
  updateTooltip(event, node)
}

function onPointerUp(event: PointerEvent) {
  if (drag.pointerId !== event.pointerId) return
  const wasMoved = drag.moved
  drag.active = false
  drag.pointerId = -1
  isDragging.value = false

  if (!wasMoved) {
    const node = nodeAt(event.clientX, event.clientY)
    if (node) selectNode(node, true)
  }
}

function onPointerLeave() {
  if (drag.active) return
  if (!tooltipVisible.value) return
  hideTooltip()
}

function canvasCenter(): { x: number; y: number } {
  const canvas = canvasRef.value
  if (!canvas) return { x: 0, y: 0 }
  const rect = canvas.getBoundingClientRect()
  return { x: rect.width / 2, y: rect.height / 2 }
}

function zoomIn() {
  const { x, y } = canvasCenter()
  zoomAt(1.35, x, y)
}

function zoomOut() {
  const { x, y } = canvasCenter()
  zoomAt(0.74, x, y)
}

function onWheel(event: WheelEvent) {
  if (!(event.ctrlKey || event.metaKey)) return
  event.preventDefault()
  const canvas = canvasRef.value
  if (!canvas) return
  const rect = canvas.getBoundingClientRect()
  zoomAt(
    event.deltaY < 0 ? 1.15 : 0.87,
    event.clientX - rect.left,
    event.clientY - rect.top,
  )
}

function onScroll() {
  // Sticky canvas + scroll offset — redraw visible window only.
  scheduleDraw()
}

function onResize() {
  if (applyingExtent) return
  scheduleDraw()
}

onMounted(() => {
  const viewport = viewportRef.value
  if (viewport) {
    resizeObserver = new ResizeObserver(onResize)
    resizeObserver.observe(viewport)
    viewport.addEventListener('scroll', onScroll, { passive: true })
  }
  scheduleDraw()
})

onUnmounted(() => {
  resizeObserver?.disconnect()
  viewportRef.value?.removeEventListener('scroll', onScroll)
  cancelAnimationFrame(drawRaf)
})
</script>

<template>
  <div class="icicle-tree">
    <header class="toolbar">
      <div class="title-row">
        <h1>平台全局需求地图</h1>
        <span v-if="bizDt" class="biz-dt">biz_dt {{ bizDt }}</span>
      </div>
      <p class="subtitle">完整分类树 · 横向为层级，纵向为分支规模</p>
    </header>

    <div
      class="content-grid"
      :class="{ 'demand-list-resizing': demandListResizing }"
      :style="{ gridTemplateColumns: `minmax(0, 1fr) ${demandListWidth}px` }"
    >
      <div class="left-panel">
        <section class="map-panel">
        <div class="dim-bar">
        <div class="tabs" role="tablist" aria-label="热度维度">
          <button
            v-for="tab in treeTabs"
            :key="tab.key"
            type="button"
            role="tab"
            class="tab"
            :class="{ active: activeTab === tab.key }"
            :aria-selected="activeTab === tab.key"
            @click="onTabClick(tab.key)"
          >
            {{ tab.label }}
          </button>
        </div>
        <div v-if="!isFullTree" class="legend" aria-hidden="true">
          <span class="legend-label">无数据</span>
          <span class="legend-swatch missing" />
          <span class="legend-label">冷</span>
          <div class="legend-bar" />
          <span class="legend-label">热</span>
        </div>
      </div>

      <div class="map-toolbar">
        <p class="map-context">{{ mapContext }}</p>
        <div class="view-actions">
          <button type="button" class="btn btn-secondary" @click="resetGlobalView">
            回到全局
          </button>
        </div>
      </div>

      <div
        class="depth-axis"
        :style="{ gridTemplateColumns: `repeat(${maxDepth + 1}, minmax(48px, 1fr))` }"
      >
        <button
          v-for="depth in maxDepth + 1"
          :key="depth - 1"
          type="button"
          :class="{ active: depth - 1 === baseDepth }"
          @click="onDepthClick(depth - 1)"
        >
          {{ depth - 1 === 0 ? '根' : `第 ${depth - 1} 级` }}
        </button>
      </div>

      <nav v-if="breadcrumbNodes.length" class="breadcrumb-bar" aria-label="当前路径">
        <template v-for="(node, index) in breadcrumbNodes" :key="node.id">
          <span v-if="index > 0" class="sep">→</span>
          <button
            type="button"
            :class="{ current: index === breadcrumbNodes.length - 1 }"
            @click="onBreadcrumbClick(node)"
          >
            {{ node.name }}
          </button>
        </template>
      </nav>
      <nav v-else class="breadcrumb-bar">
        <button type="button" class="current" @click="resetGlobalView">全局树</button>
      </nav>

      <div class="icicle-stage">
        <div ref="viewportRef" class="icicle-viewport">
          <div ref="wrapRef" class="icicle-wrap">
            <canvas
              ref="canvasRef"
              class="icicle-canvas"
              :class="{ dragging: isDragging }"
              aria-label="全局分类树冰柱图"
              @pointerdown="onPointerDown"
              @pointermove="onPointerMove"
              @pointerup="onPointerUp"
              @pointercancel="onPointerUp"
              @pointerleave="onPointerLeave"
              @wheel="onWheel"
            />
          </div>
        </div>
        <div class="icicle-overlay">
          <div
            v-if="tooltipVisible && tooltipNode"
            class="map-tooltip"
            :style="{ left: `${tooltipX}px`, top: `${tooltipY}px` }"
          >
            <strong>
              {{ tooltipNode.name }}
              <template v-if="nodeHasDemands(tooltipNode)"> 🔍</template>
            </strong>
            <span>{{ tooltipNode.path.join(' / ') }}</span>
            <span v-if="nodeHasDemands(tooltipNode)">点击可在下方展开需求路径</span>
            <span v-if="activeDim && activeDimLabel">
              {{ activeDimLabel }}：{{ formatNodeDimScore(tooltipNode, activeDim) }}
            </span>
            <span>
              第 {{ tooltipNode.path.length - 1 }} 级
              · {{ tooltipNode.children.length }} 个子节点
              · {{ tooltipNode.leafCount }} 个叶节点
            </span>
            <span v-if="tooltipNode.hung_word_count != null">
              挂靠词 {{ tooltipNode.hung_word_count }}
            </span>
            <span v-if="tooltipNode.description">{{ tooltipNode.description }}</span>
          </div>
          <div class="map-guide">
            <strong>阅读方式</strong>
            <span>滚轮浏览；拖拽平移；Ctrl/⌘+滚轮纵向缩放；点击聚焦分支。</span>
          </div>
          <div class="zoom-controls">
            <button type="button" title="缩小" @click="zoomOut">−</button>
            <button type="button" title="自适应可读高度" @click="fitView">适配</button>
            <button type="button" title="放大" @click="zoomIn">＋</button>
          </div>
        </div>
      </div>
        </section>

        <div
          v-if="inspectOpen && inspectNode"
          class="path-dock"
          :class="{ resizing: pathDockResizing }"
          :style="{ height: `${pathDockHeight}px` }"
        >
          <div
            class="path-dock-resize"
            role="separator"
            aria-orientation="horizontal"
            aria-label="调整详情区域高度"
            title="上下拖拽调整高度"
            @pointerdown="onPathDockResizeStart"
          />
          <div class="path-dock-head">
            <div class="path-dock-meta">
              <span class="path-dock-label">节点详情</span>
              <span v-if="inspectDemandName" class="path-dock-demand">{{ inspectDemandName }}</span>
              <span class="path-dock-name">{{ inspectNode.name }}</span>
            </div>
            <button
              type="button"
              class="path-dock-close"
              aria-label="关闭节点详情"
              @click="closeInspect"
            >
              关闭
            </button>
          </div>
          <div class="path-dock-body">
            <DemandPathPanel
              :open="true"
              :category-name="inspectNode.name"
              :node-name="inspectNode.name"
              :node-path="inspectNode.path.join(' / ')"
              :items="inspectItems"
              :highlight-demand-id="inspectHighlightDemandId"
              hide-inline-close
              @close="closeInspect"
            />
          </div>
        </div>
      </div>

      <div class="demand-list-shell" :class="{ resizing: demandListResizing }">
        <div
          class="demand-list-resize"
          role="separator"
          aria-orientation="vertical"
          aria-label="调整全部需求区域宽度"
          title="左右拖拽调整宽度"
          @pointerdown="onDemandListResizeStart"
        />
        <aside class="demand-list-aside">
          <DemandGradeListPanel
            v-if="visibleDemandCards.length"
            :demand-cards="visibleDemandCards"
            :selected-demand-id="listSelectedDemandId"
            :selected-category-id="listSelectedCategoryId"
            @select-demand="onSelectDemandFromList"
            @select-demand-category="onSelectDemandCategoryFromList"
          />
          <div v-else-if="demandCards.length && focus" class="demand-empty">该分类下暂无需求</div>
          <div v-else class="demand-empty">暂无需求数据</div>
        </aside>
      </div>
    </div>
  </div>
</template>

<style scoped>
.icicle-tree {
  display: flex;
  flex-direction: column;
  gap: 12px;
  height: 100%;
  min-height: 0;
}

.toolbar {
  flex-shrink: 0;
  padding-bottom: 12px;
  border-bottom: 1px solid #e2e8f0;
}

.title-row {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 10px 14px;
}

.toolbar h1 {
  margin: 0;
  font-size: 22px;
  font-weight: 700;
  color: #0f172a;
  letter-spacing: -0.02em;
}

.subtitle {
  margin: 6px 0 0;
  font-size: 13px;
  color: #64748b;
}

.biz-dt {
  font-size: 12px;
  color: #64748b;
  font-variant-numeric: tabular-nums;
}

.map-panel {
  display: grid;
  grid-template-rows: auto auto auto auto minmax(0, 1fr);
  flex: 1;
  min-height: 0;
  overflow: hidden;
  border: 1px solid #e2e8f0;
  border-radius: 10px;
  background: #fff;
}

.content-grid {
  display: grid;
  gap: 0;
  flex: 1;
  min-height: 0;
  align-items: stretch;
}

.content-grid.demand-list-resizing {
  user-select: none;
}

.left-panel {
  display: flex;
  flex-direction: column;
  gap: 12px;
  min-height: 0;
  min-width: 0;
  padding-right: 12px;
}

.demand-list-shell {
  display: flex;
  min-height: 0;
  min-width: 0;
  overflow: hidden;
}

.demand-list-resize {
  flex-shrink: 0;
  width: 10px;
  cursor: ew-resize;
  touch-action: none;
  position: relative;
  background: transparent;
}

.demand-list-resize::before {
  content: '';
  position: absolute;
  left: 50%;
  top: 50%;
  transform: translate(-50%, -50%);
  width: 4px;
  height: 40px;
  border-radius: 999px;
  background: #cbd5e1;
  transition: background 0.15s ease;
}

.demand-list-resize:hover::before,
.demand-list-shell.resizing .demand-list-resize::before {
  background: #64748b;
}

.demand-list-aside {
  flex: 1;
  min-width: 0;
  min-height: 0;
  height: 100%;
  display: flex;
  flex-direction: column;
}

.demand-list-aside :deep(.demand-list) {
  flex: 1;
  min-height: 0;
}

.demand-empty {
  flex: 1;
  min-height: 0;
  border: 1px solid #e2e8f0;
  border-radius: 10px;
  background: #fff;
  color: #94a3b8;
  font-size: 13px;
  display: flex;
  align-items: center;
  justify-content: center;
}

@media (max-width: 980px) {
  .content-grid {
    grid-template-columns: 1fr !important;
    grid-template-rows: minmax(420px, 55vh) minmax(0, 1fr);
  }

  .left-panel {
    padding-right: 0;
  }

  .demand-list-resize {
    display: none;
  }

  .demand-list-shell {
    min-height: 360px;
  }
}

.dim-bar {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 10px 16px;
  padding: 10px 14px;
  border-bottom: 1px solid #e8edf2;
  background: #f8fafc;
}

.tabs {
  display: inline-flex;
  flex-wrap: wrap;
  gap: 6px;
  padding: 4px;
  border: 1px solid #e2e8f0;
  border-radius: 10px;
  background: #fff;
}

.tab {
  height: 32px;
  padding: 0 12px;
  border: 1px solid transparent;
  border-radius: 7px;
  background: transparent;
  color: #475569;
  font-size: 13px;
  font-weight: 500;
  cursor: pointer;
}

.tab:hover {
  color: #0f172a;
  background: #f1f5f9;
}

.tab.active {
  background: #0f172a;
  border-color: #0f172a;
  color: #fff;
}

.legend {
  display: inline-flex;
  align-items: center;
  gap: 8px;
}

.legend-label {
  font-size: 11px;
  color: #94a3b8;
}

.legend-swatch {
  width: 14px;
  height: 10px;
  border-radius: 2px;
  border: 1px solid #e2e8f0;
}

.legend-swatch.missing {
  background: #ffffff;
}

.legend-bar {
  width: 120px;
  height: 10px;
  border-radius: 999px;
  border: 1px solid rgba(15, 23, 42, 0.08);
  background: linear-gradient(
    90deg,
    rgb(219, 234, 254) 0%,
    rgb(125, 211, 252) 25%,
    rgb(52, 211, 153) 50%,
    rgb(251, 191, 36) 75%,
    rgb(239, 68, 68) 100%
  );
}

.map-toolbar {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 10px 16px;
  padding: 12px 14px;
  border-bottom: 1px solid #e8edf2;
}

.map-context {
  margin: 0;
  font-size: 13px;
  color: #475569;
}

.view-actions {
  display: flex;
  gap: 8px;
}

.btn {
  height: 32px;
  padding: 0 12px;
  border: 1px solid #334155;
  border-radius: 6px;
  background: #0f172a;
  color: #fff;
  font-size: 13px;
  cursor: pointer;
}

.btn:hover:not(:disabled) {
  background: #1e293b;
}

.btn-secondary {
  background: #fff;
  color: #0f172a;
  border-color: #cbd5e1;
}

.btn-secondary:hover:not(:disabled) {
  background: #f8fafc;
  border-color: #94a3b8;
}

.path-dock {
  display: flex;
  flex-direction: column;
  flex: 0 0 auto;
  min-height: 0;
  overflow: hidden;
  overscroll-behavior: contain;
  border: 1px solid #e2e8f0;
  border-radius: 10px;
  background: #fff;
  isolation: isolate;
}

.path-dock-resize {
  flex-shrink: 0;
  height: 10px;
  cursor: ns-resize;
  touch-action: none;
  position: relative;
  background: #f8fafc;
  border-bottom: 1px solid #e2e8f0;
}

.path-dock-resize::before {
  content: '';
  position: absolute;
  left: 50%;
  top: 50%;
  transform: translate(-50%, -50%);
  width: 40px;
  height: 4px;
  border-radius: 999px;
  background: #cbd5e1;
  transition: background 0.15s ease;
}

.path-dock-resize:hover::before,
.path-dock.resizing .path-dock-resize::before {
  background: #64748b;
}

.path-dock.resizing {
  user-select: none;
}

.path-dock-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  flex-shrink: 0;
  padding: 10px 14px;
  border-bottom: 1px solid #e2e8f0;
  background: #f8fafc;
}

.path-dock-meta {
  display: flex;
  align-items: baseline;
  gap: 8px;
  min-width: 0;
}

.path-dock-label {
  flex-shrink: 0;
  font-size: 12px;
  font-weight: 700;
  color: #64748b;
  letter-spacing: 0.03em;
}

.path-dock-name {
  font-size: 14px;
  font-weight: 600;
  color: #0f172a;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.path-dock-demand {
  font-size: 14px;
  font-weight: 700;
  color: #1d4ed8;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.path-dock-close {
  flex-shrink: 0;
  height: 30px;
  padding: 0 12px;
  border: 1px solid #cbd5e1;
  border-radius: 6px;
  background: #fff;
  color: #475569;
  font-size: 13px;
  font-weight: 500;
  cursor: pointer;
}

.path-dock-close:hover {
  background: #f1f5f9;
  border-color: #94a3b8;
  color: #0f172a;
}

.path-dock-body {
  flex: 1;
  min-height: 0;
  overflow: auto;
  overscroll-behavior: contain;
  padding: 12px 14px;
  display: flex;
  flex-direction: column;
}

.depth-axis {
  display: grid;
  padding: 8px 12px;
  gap: 4px;
  border-bottom: 1px solid #edf1f5;
  background: #fbfdff;
}

.depth-axis button {
  padding: 4px 6px;
  border: 0;
  border-radius: 6px;
  background: transparent;
  color: #94a3b8;
  font-size: 12px;
  cursor: pointer;
}

.depth-axis button:hover {
  background: #eff6ff;
  color: #1d4ed8;
}

.depth-axis button.active {
  background: #dbeafe;
  color: #1d4ed8;
  font-weight: 600;
}

.breadcrumb-bar {
  display: flex;
  align-items: center;
  gap: 4px;
  min-height: 34px;
  overflow-x: auto;
  padding: 6px 12px;
  border-bottom: 1px solid #edf1f5;
  background: #fff;
}

.breadcrumb-bar button {
  flex: 0 0 auto;
  padding: 4px 8px;
  border: 0;
  border-radius: 5px;
  background: #f1f5f9;
  color: #526174;
  font-size: 12px;
  cursor: pointer;
}

.breadcrumb-bar button.current {
  background: #ede9fe;
  color: #6d28d9;
  font-weight: 600;
}

.sep {
  color: #94a3b8;
  font-size: 12px;
}

.icicle-stage {
  position: relative;
  display: flex;
  flex-direction: column;
  min-height: 0;
  margin: 12px;
  overflow: hidden;
  border: 1px solid #cbd5e1;
  border-radius: 10px;
  background: #f8fafc;
}

.icicle-viewport {
  position: relative;
  flex: 1;
  min-height: 0;
  overflow: auto;
  background: #f8fafc;
  scrollbar-gutter: stable;
}

.icicle-overlay {
  position: absolute;
  inset: 0;
  z-index: 5;
  pointer-events: none;
}

.icicle-wrap {
  position: relative;
  min-height: 0;
  overflow: visible;
  background: #f8fafc;
}

.icicle-canvas {
  position: sticky;
  top: 0;
  left: 0;
  z-index: 1;
  display: block;
  cursor: grab;
  touch-action: none;
}

.icicle-canvas.dragging {
  cursor: grabbing;
}

.map-tooltip {
  position: absolute;
  z-index: 8;
  width: 240px;
  padding: 10px 12px;
  border: 1px solid #c4b5fd;
  border-radius: 8px;
  background: rgba(255, 255, 255, 0.97);
  color: #334155;
  box-shadow: 0 9px 24px rgba(15, 23, 42, 0.14);
  font-size: 12px;
  line-height: 1.55;
  pointer-events: none;
}

.map-tooltip strong {
  display: block;
  margin-bottom: 4px;
  color: #5b21b6;
  font-size: 13px;
}

.map-tooltip span {
  display: block;
  color: #64748b;
}

.map-guide {
  position: absolute;
  right: 10px;
  top: 10px;
  z-index: 5;
  width: 200px;
  padding: 10px 12px;
  border: 1px solid #bfdbfe;
  border-radius: 8px;
  background: rgba(255, 255, 255, 0.94);
  color: #526174;
  box-shadow: 0 7px 18px rgba(15, 23, 42, 0.07);
  pointer-events: none;
  font-size: 11px;
  line-height: 1.5;
}

.map-guide strong {
  display: block;
  margin-bottom: 4px;
  color: #1d4ed8;
  font-size: 12px;
}

.zoom-controls {
  position: absolute;
  right: 10px;
  bottom: 10px;
  z-index: 6;
  display: flex;
  gap: 4px;
  pointer-events: auto;
}

.zoom-controls button {
  min-width: 32px;
  height: 30px;
  padding: 0 8px;
  border: 1px solid #bfdbfe;
  border-radius: 6px;
  background: #fff;
  color: #1d4ed8;
  font-size: 14px;
  cursor: pointer;
}

.zoom-controls button:hover {
  background: #eff6ff;
}
</style>
