<template>
  <div
    ref="mindMapViewport"
    class="org-mind-map"
    :class="{ 'org-mind-map--panning': isCanvasPanning }"
    @mousedown="startCanvasPan"
  >
    <svg
      class="org-mind-map-canvas"
      :viewBox="`0 0 ${canvasWidth} ${canvasHeight}`"
      :style="scaledCanvasStyle"
      role="img"
      aria-label="组织权限脑图"
    >
      <g class="org-mind-map-connectors" fill="none">
        <path
          v-for="connector in layout.connectors"
          :key="connector.key"
          class="org-mind-map-connector"
          :d="`M ${connector.fromX} ${connector.fromY} H ${connector.midX} V ${connector.toY} H ${connector.toX}`"
        />
      </g>

      <g
        v-for="node in layout.nodes"
        :key="node.key"
        :class="['org-mind-map-node', { 'org-mind-map-node--employee': node.isEmployee }]"
        :transform="`translate(${node.x} ${node.y})`"
        tabindex="0"
        role="button"
        @click="emit('select-node', node.raw)"
        @contextmenu.prevent.stop="emit('node-contextmenu', { node: node.raw, x: $event.clientX, y: $event.clientY })"
        @keyup.enter="emit('select-node', node.raw)"
      >
        <title>{{ node.title }} · {{ node.subtitle }}</title>
        <rect class="org-mind-map-card" :width="node.width" :height="NODE_HEIGHT" rx="16" />
        <text class="org-mind-map-title" x="18" y="31">{{ node.displayTitle }}</text>
        <text class="org-mind-map-subtitle" x="18" y="57">{{ node.displaySubtitle }}</text>
        <text
          v-if="!node.isEmployee && node.employee_count"
          class="org-mind-map-employee-count"
          x="18"
          y="76"
        >
          {{ node.employee_count }} 人
        </text>

        <g
          v-if="!node.isEmployee && node.employee_count"
          class="org-mind-map-employee-toggle"
          :transform="`translate(${node.width - 38} 14)`"
          role="button"
          tabindex="0"
          @click.stop="toggleEmployeeLeaves(node.raw)"
          @contextmenu.prevent.stop
          @keyup.enter.stop="toggleEmployeeLeaves(node.raw)"
        >
          <circle r="15" cx="15" cy="15" />
          <text x="15" y="20" text-anchor="middle">{{ isEmployeeExpanded(node.raw) ? "−" : "+" }}</text>
        </g>
      </g>
    </svg>
  </div>
</template>

<script setup>
import { computed, nextTick, onBeforeUnmount, ref, watch } from "vue";

const props = defineProps({
  nodes: {
    type: Array,
    default: () => []
  },
  zoomScale: {
    type: Number,
    default: 1
  },
  fitRequestKey: {
    type: Number,
    default: 0
  }
});

const emit = defineEmits(["select-node", "node-contextmenu", "toggle-employee-leaves", "fit-scale"]);

const NODE_MIN_WIDTH = 190;
const NODE_MAX_WIDTH = 420;
const NODE_HEIGHT = 92;
const TEXT_PADDING_X = 40;
const TOGGLE_RESERVE_WIDTH = 52;
const COLUMN_GAP = 150;
const ROW_GAP = 34;
const CANVAS_PADDING = 24;

const COLUMN_WIDTH = NODE_MAX_WIDTH + COLUMN_GAP;
const ROW_HEIGHT = NODE_HEIGHT + ROW_GAP;
const ROOT_GROUP_GAP = 1;
const FIT_SCALE_MIN = 0.12;
const FIT_SCALE_MAX = 1;

const mindMapViewport = ref(null);
const isCanvasPanning = ref(false);
const canvasPan = ref({
  startX: 0,
  startY: 0,
  scrollLeft: 0,
  scrollTop: 0
});
const expandedEmployeeNodeIds = ref(new Set());
const collapsedEmployeeNodeIds = computed(() => {
  const collapsed = new Set();
  for (const node of flattenOrgNodes(props.nodes)) {
    if ((node.employees || []).length && !expandedEmployeeNodeIds.value.has(node.node_id)) {
      collapsed.add(node.node_id);
    }
  }
  return collapsed;
});

function flattenOrgNodes(nodes) {
  const result = [];
  for (const node of nodes || []) {
    result.push(node);
    result.push(...flattenOrgNodes(node.children || []));
  }
  return result;
}

function isEmployeeExpanded(node) {
  return expandedEmployeeNodeIds.value.has(node?.node_id);
}

function toggleEmployeeLeaves(node) {
  if (!node?.node_id) {
    return;
  }
  const next = new Set(expandedEmployeeNodeIds.value);
  if (next.has(node.node_id)) {
    next.delete(node.node_id);
  } else {
    next.add(node.node_id);
  }
  expandedEmployeeNodeIds.value = next;
  emit("toggle-employee-leaves", node);
}

function shouldIgnoreCanvasPan(event) {
  const target = event.target;
  return Boolean(
    target?.closest?.(".org-mind-map-node") ||
      target?.closest?.(".org-mind-map-employee-toggle")
  );
}

function startCanvasPan(event) {
  if (event.button !== 0 || shouldIgnoreCanvasPan(event)) {
    return;
  }
  const viewport = mindMapViewport.value;
  if (!viewport) {
    return;
  }
  event.preventDefault();
  isCanvasPanning.value = true;
  canvasPan.value = {
    startX: event.clientX,
    startY: event.clientY,
    scrollLeft: viewport.scrollLeft,
    scrollTop: viewport.scrollTop
  };
  window.addEventListener("mousemove", moveCanvasPan);
  window.addEventListener("mouseup", endCanvasPan);
}

function moveCanvasPan(event) {
  if (!isCanvasPanning.value) {
    return;
  }
  const viewport = mindMapViewport.value;
  if (!viewport) {
    return;
  }
  const deltaX = event.clientX - canvasPan.value.startX;
  const deltaY = event.clientY - canvasPan.value.startY;
  event.preventDefault();
  viewport.scrollLeft = canvasPan.value.scrollLeft - deltaX;
  viewport.scrollTop = canvasPan.value.scrollTop - deltaY;
}

function endCanvasPan() {
  isCanvasPanning.value = false;
  window.removeEventListener("mousemove", moveCanvasPan);
  window.removeEventListener("mouseup", endCanvasPan);
}

function estimateTextWidth(value, fontSize = 16) {
  return Array.from(String(value || "")).reduce((width, char) => {
    if (/[\u4e00-\u9fff\uff00-\uffef]/.test(char)) {
      return width + fontSize;
    }
    if (/\s/.test(char)) {
      return width + fontSize * 0.35;
    }
    if (/[A-Z0-9]/.test(char)) {
      return width + fontSize * 0.62;
    }
    return width + fontSize * 0.55;
  }, 0);
}

function clamp(value, min, max) {
  return Math.min(max, Math.max(min, value));
}

function fitTextToWidth(value, maxWidth, fontSize = 16) {
  const text = String(value || "");
  if (estimateTextWidth(text, fontSize) <= maxWidth) {
    return text;
  }
  let result = "";
  for (const char of Array.from(text)) {
    const next = `${result}${char}`;
    if (estimateTextWidth(`${next}...`, fontSize) > maxWidth) {
      return `${result}...`;
    }
    result = next;
  }
  return result;
}

function computeNodeWidth({ title, subtitle, hasEmployeeToggle }) {
  const toggleReserve = hasEmployeeToggle ? TOGGLE_RESERVE_WIDTH : 0;
  const titleWidth = estimateTextWidth(title, 16) + TEXT_PADDING_X + toggleReserve;
  const subtitleWidth = estimateTextWidth(subtitle, 13) + TEXT_PADDING_X;
  return clamp(Math.ceil(Math.max(titleWidth, subtitleWidth)), NODE_MIN_WIDTH, NODE_MAX_WIDTH);
}

function toVisualChildren(node) {
  const orgChildren = Array.isArray(node.children) ? node.children : [];
  const shouldShowEmployees = Boolean(node.node_id) && !collapsedEmployeeNodeIds.value.has(node.node_id);
  const employeeLeaves = shouldShowEmployees && Array.isArray(node.employees)
      ? node.employees.map((employee) => ({
        ...employee,
        parent_node_id: node.node_id,
        parent_node_label: node.node_label,
        node_type: "EMPLOYEE",
        node_label: employee.employee_name,
        children: [],
        employees: []
      }))
    : [];
  return [...orgChildren, ...employeeLeaves];
}

function normalizeNode(node, index, parentKey = "root") {
  const isEmployee = node.node_type === "EMPLOYEE" || Boolean(node.employee_id && !node.node_id);
  const id = node.node_id ?? node.employee_id ?? `${parentKey}-${index}`;
  const key = `${isEmployee ? "employee" : "org"}-${id}`;
  const managerSubtitle = Array.isArray(node.manager_names) && node.manager_names.length
    ? node.manager_names.join("、")
    : node.manager_name || node.subtitle || "未设置主管";
  const title = node.node_label || node.employee_name || node.title || "未命名节点";
  const subtitle = isEmployee ? node.mobile || "未设置电话" : managerSubtitle;
  const employeeCount = Array.isArray(node.employees) ? node.employees.length : 0;
  const width = computeNodeWidth({
    title,
    subtitle,
    hasEmployeeToggle: !isEmployee && employeeCount > 0
  });
  return {
    raw: node,
    key,
    isEmployee,
    width,
    employee_count: employeeCount,
    title,
    subtitle,
    displayTitle: fitTextToWidth(title, width - TEXT_PADDING_X - (!isEmployee && employeeCount ? TOGGLE_RESERVE_WIDTH : 0), 16),
    displaySubtitle: fitTextToWidth(subtitle, width - TEXT_PADDING_X, 13),
    children: toVisualChildren(node).map((child, childIndex) => normalizeNode(child, childIndex, key))
  };
}

function normalizeNodes(nodes) {
  return nodes.map((node, index) => normalizeNode(node, index));
}

function measureSubtree(node) {
  if (!node.children.length) {
    node.subtreeSpan = 1;
    return node.subtreeSpan;
  }

  node.subtreeSpan = node.children.reduce((total, child) => total + measureSubtree(child), 0);
  return node.subtreeSpan;
}

function segmentKey(segment) {
  const x1 = Math.round(segment.x1 * 100) / 100;
  const y1 = Math.round(segment.y1 * 100) / 100;
  const x2 = Math.round(segment.x2 * 100) / 100;
  const y2 = Math.round(segment.y2 * 100) / 100;
  const start = `${Math.min(x1, x2)},${Math.min(y1, y2)}`;
  const end = `${Math.max(x1, x2)},${Math.max(y1, y2)}`;
  return `${start}-${end}`;
}

function connectorSegments(parent, child, laneX, trunkFromY, trunkToY) {
  const parentEdgeX = parent.x + parent.width;
  const parentCenterY = parent.y + NODE_HEIGHT / 2;
  const childEdgeX = child.x;
  const childCenterY = child.y + NODE_HEIGHT / 2;

  // Orthogonal connector logic: edge stems stay outside cards, trunk stays in the column gutter.
  return [
    { x1: parentEdgeX, y1: parentCenterY, x2: laneX, y2: parentCenterY },
    { x1: laneX, y1: trunkFromY, x2: laneX, y2: trunkToY },
    { x1: laneX, y1: childCenterY, x2: childEdgeX, y2: childCenterY }
  ];
}

function toConnector(segment) {
  return {
    key: segmentKey(segment),
    fromX: segment.x1,
    fromY: segment.y1,
    midX: segment.x2,
    toY: segment.y2,
    toX: segment.x2
  };
}

function layoutTree(rootNodes) {
  const nodes = [];
  const connectors = [];
  const connectorMap = new Map();
  let nextLeafRow = 0;
  let maxDepth = 0;

  function placeNode(node, depth) {
    maxDepth = Math.max(maxDepth, depth);

    if (!node.children.length) {
      const row = nextLeafRow;
      nextLeafRow += 1;
      const layoutNode = {
        ...node,
        depth,
        row,
        x: CANVAS_PADDING + depth * COLUMN_WIDTH,
        y: CANVAS_PADDING + row * ROW_HEIGHT
      };
      nodes.push(layoutNode);
      return layoutNode;
    }

    const childNodes = node.children.map((child) => placeNode(child, depth + 1));
    const firstChild = childNodes[0];
    const lastChild = childNodes[childNodes.length - 1];
    const row = (firstChild.row + lastChild.row) / 2;
    const layoutNode = {
      ...node,
      depth,
      row,
      x: CANVAS_PADDING + depth * COLUMN_WIDTH,
      y: CANVAS_PADDING + row * ROW_HEIGHT
    };
    nodes.push(layoutNode);

    const laneX = layoutNode.x + layoutNode.width + COLUMN_GAP / 2;
    const childCenterYs = childNodes.map((child) => child.y + NODE_HEIGHT / 2);
    const trunkFromY = Math.min(...childCenterYs);
    const trunkToY = Math.max(...childCenterYs);

    childNodes.forEach((child) => {
      connectorSegments(layoutNode, child, laneX, trunkFromY, trunkToY).forEach((segment) => {
        const key = segmentKey(segment);
        if (!connectorMap.has(key)) {
          connectorMap.set(key, true);
          connectors.push(toConnector(segment));
        }
      });
    });

    return layoutNode;
  }

  rootNodes.forEach((rootNode, index) => {
    measureSubtree(rootNode);
    placeNode(rootNode, 0);
    if (index < rootNodes.length - 1) {
      nextLeafRow += ROOT_GROUP_GAP;
    }
  });

  const maxRow = Math.max(0, ...nodes.map((node) => node.row));

  return {
    nodes,
    connectors,
    width: CANVAS_PADDING * 2 + NODE_MAX_WIDTH + maxDepth * COLUMN_WIDTH,
    height: CANVAS_PADDING * 2 + NODE_HEIGHT + maxRow * ROW_HEIGHT
  };
}

const layout = computed(() => layoutTree(normalizeNodes(props.nodes)));
const canvasWidth = computed(() => Math.max(760, layout.value.width));
const canvasHeight = computed(() => Math.max(460, layout.value.height));
const scaledCanvasStyle = computed(() => ({
  width: `${canvasWidth.value * props.zoomScale}px`,
  height: `${canvasHeight.value * props.zoomScale}px`
}));

function centerCanvasInViewport() {
  const viewport = mindMapViewport.value;
  if (!viewport) {
    return;
  }
  viewport.scrollLeft = Math.max(0, (viewport.scrollWidth - viewport.clientWidth) / 2);
  viewport.scrollTop = Math.max(0, (viewport.scrollHeight - viewport.clientHeight) / 2);
}

async function fitCanvasToViewport() {
  await nextTick();
  const viewport = mindMapViewport.value;
  if (!viewport) {
    return;
  }
  const availableWidth = Math.max(1, viewport.clientWidth - 24);
  const availableHeight = Math.max(1, viewport.clientHeight - 24);
  const widthScale = availableWidth / canvasWidth.value;
  const heightScale = availableHeight / canvasHeight.value;
  const nextScale = clamp(Math.min(widthScale, heightScale), FIT_SCALE_MIN, FIT_SCALE_MAX);
  emit("fit-scale", Number(nextScale.toFixed(2)));
  requestAnimationFrame(centerCanvasInViewport);
}

watch(
  () => props.fitRequestKey,
  () => {
    if (props.fitRequestKey > 0) {
      fitCanvasToViewport();
    }
  },
  { immediate: true }
);

onBeforeUnmount(() => {
  endCanvasPan();
});

defineExpose({
  fitCanvasToViewport
});
</script>

<style scoped>
.org-mind-map {
  width: 100%;
  height: clamp(520px, 68vh, 760px);
  min-height: 460px;
  overflow: auto;
  overscroll-behavior: contain;
  border: 1px solid rgba(58, 82, 105, 0.18);
  border-radius: 24px;
  background:
    linear-gradient(90deg, rgba(58, 82, 105, 0.06) 1px, transparent 1px) 0 0 / 28px 28px,
    linear-gradient(0deg, rgba(58, 82, 105, 0.05) 1px, transparent 1px) 0 0 / 28px 28px,
    radial-gradient(circle at 12% 12%, rgba(14, 165, 233, 0.12), transparent 30%),
    linear-gradient(135deg, rgba(248, 250, 252, 0.98), rgba(232, 239, 247, 0.72));
  background-size:
    28px 28px,
    28px 28px,
    auto,
    auto;
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.88);
  cursor: grab;
  user-select: none;
}

.org-mind-map--panning {
  cursor: grabbing;
}

.org-mind-map-canvas {
  display: block;
}

.org-mind-map-connector {
  stroke: rgba(79, 115, 148, 0.7);
  stroke-width: 2.25;
  stroke-linecap: square;
  stroke-linejoin: miter;
  filter: drop-shadow(0 1px 0 rgba(255, 255, 255, 0.76));
}

.org-mind-map-node {
  cursor: pointer;
  outline: none;
}

.org-mind-map-node:focus .org-mind-map-card,
.org-mind-map-node:hover .org-mind-map-card {
  stroke: #0ea5e9;
  stroke-width: 2;
  filter: drop-shadow(0 18px 26px rgba(37, 99, 235, 0.18));
}

.org-mind-map-card {
  fill: rgba(255, 255, 255, 0.96);
  stroke: rgba(58, 82, 105, 0.2);
  filter: drop-shadow(0 14px 22px rgba(15, 23, 42, 0.11));
}

.org-mind-map-node--employee .org-mind-map-card {
  fill: rgba(240, 253, 250, 0.96);
  stroke: rgba(20, 184, 166, 0.34);
}

.org-mind-map-title {
  fill: #142033;
  font-size: 16px;
  font-weight: 800;
  letter-spacing: 0.02em;
}

.org-mind-map-subtitle,
.org-mind-map-employee-count {
  fill: #647587;
  font-size: 13px;
}

.org-mind-map-node--employee .org-mind-map-title {
  fill: #0f766e;
}

.org-mind-map-employee-toggle {
  cursor: pointer;
}

.org-mind-map-employee-toggle circle {
  fill: #e0f2fe;
  stroke: rgba(14, 165, 233, 0.38);
}

.org-mind-map-employee-toggle text {
  fill: #0369a1;
  font-size: 18px;
  font-weight: 900;
  pointer-events: none;
}
</style>
