<script setup lang="ts">
/* eslint-disable vue/html-closing-bracket-newline -- 与项目 Prettier 的 Vue 多行标签格式保持一致。 */
import type {
  DataspaceColumn,
  DataspaceConnectionView,
  DataspaceId,
  DataspaceObjectView,
  DataspaceRelationDefinition,
  DataspaceSummary,
  DataspaceTableView,
} from '#/api/dataspace';

import {
  computed,
  nextTick,
  onBeforeUnmount,
  onMounted,
  reactive,
  ref,
} from 'vue';

import {
  ChevronDown,
  ChevronLeft,
  ChevronRight,
  ChevronUp,
  CircleAlert,
  Columns3,
  Database,
  Eye,
  EyeOff,
  KeyRound,
  Maximize,
  Pencil,
  Plus,
  Search,
  SquareTerminal,
  Table2,
  Trash2,
  X,
} from '@easyflow/icons';

import {
  ElButton,
  ElEmpty,
  ElInput,
  ElMessage,
  ElMessageBox,
  ElTag,
} from 'element-plus';

import {
  getDataspace,
  listDataspaceConnections,
  listDataspaceObjects,
  listDataspaces,
  removeDataspace,
  saveDataspace,
  setDataspaceEnabled,
} from '#/api/dataspace';
import mysqlIcon from '#/assets/datacenter/mysql-icon.svg';
import postgresqlIcon from '#/assets/datacenter/postgresql-icon.svg';

import { dataspaceErrorMessage } from '../dataspace-errors';
import {
  createLogicalTableName,
  logicalTableNameError,
} from '../dataspace-logical-name';

interface EditorTable {
  clientKey: string;
  columns: DataspaceColumn[];
  connectionId: DataspaceId;
  connectionName: string;
  databaseType: 'MYSQL' | 'POSTGRESQL';
  objectId: DataspaceId;
  objectName: string;
  issueCode?: number;
  issueMessage?: string;
  tableAlias: string;
  positionX: number;
  positionY: number;
  schemaAlias: string;
  schemaName: string;
  sourceAlias: string;
  status: 'ACTIVE' | 'CONNECTION_DISABLED' | 'MISSING';
}

interface EditorRelation extends DataspaceRelationDefinition {
  issueCode?: number;
  issueMessage?: string;
  key: string;
  status: 'ACTIVE' | 'INVALID';
}

interface LinkEndpoint {
  clientKey: string;
  column: string;
  side: 'left' | 'right';
}

interface CanvasPoint {
  x: number;
  y: number;
}

interface SourceObjectGroup {
  connection: DataspaceConnectionView;
  schemas: Array<{ name: string; objects: DataspaceObjectView[] }>;
}

const emit = defineEmits<{
  changed: [];
  openWorkbench: [dataspaceId: DataspaceId];
}>();

defineSlots<{
  'workspace-navigation'(props: { dataspaceId?: DataspaceId }): unknown;
}>();

const loading = ref(false);
const saving = ref(false);
const editorError = ref('');
const statusChangingId = ref<DataspaceId>();
const mode = ref<'editor' | 'list'>('list');
const keyword = ref('');
const spaces = ref<DataspaceSummary[]>([]);
const connections = ref<DataspaceConnectionView[]>([]);
const sourceObjects = ref<Map<string, DataspaceObjectView[]>>(new Map());
const objectKeyword = ref('');
const editorTables = ref<EditorTable[]>([]);
const editorRelations = ref<EditorRelation[]>([]);
const selectedRelationKey = ref<string>();
const editingTableKey = ref<string>();
const logicalNameDraft = ref('');
const expandedTableKeys = ref<Set<string>>(new Set());
const collapsedConnectionIds = ref<Set<string>>(new Set());
const collapsedSchemaKeys = ref<Set<string>>(new Set());
const linkStart = ref<LinkEndpoint>();
const linkPointer = reactive<CanvasPoint>({ x: 0, y: 0 });
const canvasRef = ref<HTMLElement>();
const canvasPanning = ref(false);
const canvasView = reactive({ x: 0, y: 0, zoom: 1 });
const editor = reactive({
  description: '',
  id: undefined as DataspaceId | undefined,
  name: '',
  revision: 0,
});
let listEpoch = 0;
let dragState:
  | undefined
  | { clientKey: string; offsetX: number; offsetY: number };
let canvasPanState:
  | undefined
  | {
      startClientX: number;
      startClientY: number;
      startX: number;
      startY: number;
    };
let draggedObject:
  | undefined
  | {
      connection: DataspaceConnectionView;
      object: DataspaceObjectView;
    };

const DEFAULT_VISIBLE_COLUMNS = 6;
const NODE_HEADER_HEIGHT = 58;
const NODE_ROW_HEIGHT = 36;
const NODE_WIDTH = 292;
const CANVAS_FIT_PADDING = 48;
const MIN_CANVAS_ZOOM = 0.4;
const MAX_CANVAS_ZOOM = 1;

const filteredSpaces = computed(() => {
  const normalized = keyword.value.trim().toLowerCase();
  if (!normalized) return spaces.value;
  return spaces.value.filter((item) =>
    [item.name, item.description].some((value) =>
      String(value || '')
        .toLowerCase()
        .includes(normalized),
    ),
  );
});
const sourceGroups = computed<SourceObjectGroup[]>(() => {
  const normalized = objectKeyword.value.trim().toLowerCase();
  return connections.value
    .map((connection) => {
      const schemaMap = new Map<string, DataspaceObjectView[]>();
      for (const object of sourceObjects.value.get(String(connection.id)) ||
        []) {
        const schema = object.schemaName || object.catalogName || 'default';
        const logicalName =
          findEditorTable(connection, object)?.tableAlias.toLowerCase() || '';
        if (
          normalized &&
          !schema.toLowerCase().includes(normalized) &&
          !object.objectName.toLowerCase().includes(normalized) &&
          !logicalName.includes(normalized) &&
          !object.columns.some((column) =>
            column.name.toLowerCase().includes(normalized),
          )
        ) {
          continue;
        }
        const rows = schemaMap.get(schema) || [];
        rows.push(object);
        schemaMap.set(schema, rows);
      }
      return {
        connection,
        schemas: [...schemaMap.entries()].map(([name, objects]) => ({
          name,
          objects,
        })),
      };
    })
    .filter((item) => item.schemas.length > 0 || !normalized);
});
const selectedRelation = computed(() =>
  editorRelations.value.find(
    (relation) => relation.key === selectedRelationKey.value,
  ),
);
const previewPath = computed(() => {
  if (!linkStart.value) return '';
  const table = editorTables.value.find(
    (item) => item.clientKey === linkStart.value?.clientKey,
  );
  if (!table) return '';
  const start = endpointPoint(
    table,
    linkStart.value.column,
    linkStart.value.side,
  );
  return curvePath(start, linkPointer);
});
const canvasExtentStyle = computed(() => {
  let width = 0;
  let height = 0;
  for (const table of editorTables.value) {
    const expandHeight =
      table.columns.length > DEFAULT_VISIBLE_COLUMNS ? 34 : 0;
    width = Math.max(width, table.positionX + NODE_WIDTH + 64);
    height = Math.max(
      height,
      table.positionY +
        NODE_HEADER_HEIGHT +
        visibleColumns(table).length * NODE_ROW_HEIGHT +
        expandHeight +
        64,
    );
  }
  return {
    height: `max(100%, ${height}px)`,
    width: `max(100%, ${width}px)`,
  };
});
const canvasSceneStyle = computed(() => ({
  ...canvasExtentStyle.value,
  transform: `translate(${canvasView.x}px, ${canvasView.y}px) scale(${canvasView.zoom})`,
}));
const canvasViewportStyle = computed(() => ({
  backgroundPosition: `${canvasView.x}px ${canvasView.y}px`,
  backgroundSize: `${16 * canvasView.zoom}px ${16 * canvasView.zoom}px`,
}));

/** 加载数据空间列表。 */
async function loadSpaces() {
  const epoch = ++listEpoch;
  loading.value = true;
  try {
    const rows = await listDataspaces();
    if (epoch !== listEpoch) return;
    spaces.value = rows;
  } finally {
    if (epoch === listEpoch) loading.value = false;
  }
}

/** 打开空白编排器。 */
async function createSpace() {
  resetEditor();
  mode.value = 'editor';
  await loadSources();
}

/** 打开已有数据空间当前 revision。 */
async function editSpace(id: DataspaceId) {
  const detail = await getDataspace(id);
  editor.id = detail.id;
  editor.name = detail.name;
  editor.description = detail.description || '';
  editor.revision = detail.currentRevision;
  editorTables.value = detail.tables.map((table) => fromTableView(table));
  const keyByBinding = new Map(
    detail.tables.map((table) => [String(table.id), `binding:${table.id}`]),
  );
  editorRelations.value = detail.relations.map((relation) => ({
    issueCode: relation.issueCode,
    issueMessage: relation.issueMessage,
    joinType: relation.joinType,
    key: `relation:${relation.id}`,
    leftClientKey: keyByBinding.get(String(relation.leftBindingId)) || '',
    leftColumn: relation.leftColumn,
    rightClientKey: keyByBinding.get(String(relation.rightBindingId)) || '',
    rightColumn: relation.rightColumn,
    status: relation.status,
  }));
  editorError.value = '';
  selectedRelationKey.value = undefined;
  expandedTableKeys.value = new Set();
  collapsedConnectionIds.value = new Set();
  collapsedSchemaKeys.value = new Set();
  resetCanvasView();
  mode.value = 'editor';
  await loadSources();
}

/** 将持久化表绑定转换为可编辑画布节点。 */
function fromTableView(table: DataspaceTableView): EditorTable {
  return {
    clientKey: `binding:${table.id}`,
    columns: table.columns,
    connectionId: table.connectionId,
    connectionName: table.connectionName,
    databaseType: table.databaseType,
    issueCode: table.issueCode,
    issueMessage: table.issueMessage,
    objectId: table.objectId,
    objectName: table.objectName,
    positionX: table.positionX ?? 80,
    positionY: table.positionY ?? 80,
    schemaAlias: table.schemaAlias,
    schemaName: table.schemaName || table.catalogName || 'default',
    sourceAlias: table.sourceAlias,
    tableAlias: table.tableAlias,
    status: table.status,
  };
}

/** 清空编排器草稿。 */
function resetEditor() {
  editor.id = undefined;
  editor.name = '';
  editor.description = '';
  editor.revision = 0;
  editorTables.value = [];
  editorRelations.value = [];
  editorError.value = '';
  selectedRelationKey.value = undefined;
  expandedTableKeys.value = new Set();
  collapsedConnectionIds.value = new Set();
  collapsedSchemaKeys.value = new Set();
  cancelLink();
  resetCanvasView();
}

/** 懒加载各物理连接元数据供编排器选择。 */
async function loadSources() {
  const rows = await listDataspaceConnections();
  connections.value = rows.filter(
    (connection) => connection.status === 'ENABLED',
  );
  const pairs = await Promise.all(
    connections.value.map(
      async (connection) =>
        [
          String(connection.id),
          await listDataspaceObjects(connection.id),
        ] as const,
    ),
  );
  sourceObjects.value = new Map(pairs);
  // 详情中的历史绑定按物理身份无感跟进到最新元数据对象。
  editorTables.value = editorTables.value.map((table) => {
    const connection = connections.value.find(
      (item) => String(item.id) === String(table.connectionId),
    );
    const current = (
      sourceObjects.value.get(String(table.connectionId)) || []
    ).find((object) => {
      const schemaName = object.schemaName || object.catalogName || 'default';
      return (
        samePhysicalName(
          connection?.databaseType,
          schemaName,
          table.schemaName,
        ) &&
        samePhysicalName(
          connection?.databaseType,
          object.objectName,
          table.objectName,
        )
      );
    });
    return {
      ...table,
      databaseType: connection?.databaseType || table.databaseType,
      ...(current
        ? {
            columns: current.columns,
            issueCode: undefined,
            issueMessage: undefined,
            objectId: current.id,
            status: 'ACTIVE' as const,
          }
        : {}),
    };
  });
}

/** 将物理表加入画布，位置采用确定性网格避免节点重叠。 */
function addObject(
  connection: DataspaceConnectionView,
  object: DataspaceObjectView,
  point?: CanvasPoint,
) {
  if (findEditorTable(connection, object)) {
    ElMessage.info('该表已在数据空间中');
    return;
  }
  const index = editorTables.value.length;
  const sourceIndex = connections.value.findIndex(
    (item) => String(item.id) === String(connection.id),
  );
  const sourceAlias = `${connection.databaseType === 'MYSQL' ? 'MYSQL' : 'PG'}_${sourceIndex + 1}`;
  const tableAlias = createLogicalTableName(
    object.objectName,
    sourceAlias,
    editorTables.value.map((table) => table.tableAlias),
  );
  const canvasColumns = Math.max(
    1,
    Math.floor(((canvasRef.value?.clientWidth || 760) - 80) / 340),
  );
  editorTables.value.push({
    clientKey: `object:${object.id}`,
    columns: object.columns,
    connectionId: connection.id,
    connectionName: connection.name,
    databaseType: connection.databaseType,
    issueCode: undefined,
    issueMessage: undefined,
    objectId: object.id,
    objectName: object.objectName,
    positionX: Math.max(24, point?.x ?? 48 + (index % canvasColumns) * 340),
    positionY: Math.max(
      24,
      point?.y ?? 48 + Math.floor(index / canvasColumns) * 340,
    ),
    schemaAlias: connection.databaseType === 'MYSQL' ? 'MAIN' : 'PUBLIC',
    schemaName: object.schemaName || object.catalogName || 'default',
    sourceAlias,
    status: 'ACTIVE',
    tableAlias,
  });
}

/** 进入画布节点的逻辑表名行内编辑状态。 */
async function beginLogicalNameEdit(table: EditorTable) {
  editingTableKey.value = table.clientKey;
  logicalNameDraft.value = table.tableAlias;
  await nextTick();
  const input = document.querySelector<HTMLInputElement>(
    `[data-logical-name-editor="${CSS.escape(table.clientKey)}"]`,
  );
  input?.focus();
  input?.select();
}

/** 保存逻辑表名；无效输入保持原值并给出就近提示。 */
function commitLogicalNameEdit(table: EditorTable) {
  const error = logicalTableNameError(
    logicalNameDraft.value,
    editorTables.value
      .filter((item) => item.clientKey !== table.clientKey)
      .map((item) => item.tableAlias),
  );
  if (error) {
    ElMessage.warning(error);
    logicalNameDraft.value = table.tableAlias;
  } else {
    table.tableAlias = logicalNameDraft.value.trim();
  }
  editingTableKey.value = undefined;
}

/** 放弃当前逻辑表名编辑。 */
function cancelLogicalNameEdit() {
  editingTableKey.value = undefined;
  logicalNameDraft.value = '';
}

/** 删除画布节点及其关系。 */
function removeEditorTable(clientKey: string) {
  editorTables.value = editorTables.value.filter(
    (item) => item.clientKey !== clientKey,
  );
  editorRelations.value = editorRelations.value.filter(
    (item) =>
      item.leftClientKey !== clientKey && item.rightClientKey !== clientKey,
  );
  if (linkStart.value?.clientKey === clientKey) {
    cancelLink();
  }
  expandedTableKeys.value.delete(clientKey);
}

/** 开始拖动画布节点。 */
function beginDrag(event: PointerEvent, table: EditorTable) {
  if (!canvasRef.value) return;
  event.preventDefault();
  const point = canvasPointFromPointer(event);
  dragState = {
    clientKey: table.clientKey,
    offsetX: point.x - table.positionX,
    offsetY: point.y - table.positionY,
  };
  window.addEventListener('pointermove', handleDrag);
  window.addEventListener('pointerup', endDrag, { once: true });
}

/** 更新拖动节点位置并限制在画布内。 */
function handleDrag(event: PointerEvent) {
  if (!dragState || !canvasRef.value) return;
  const target = editorTables.value.find(
    (item) => item.clientKey === dragState?.clientKey,
  );
  if (!target) return;
  const point = canvasPointFromPointer(event);
  target.positionX = Math.max(16, point.x - dragState.offsetX);
  target.positionY = Math.max(16, point.y - dragState.offsetY);
}

/** 结束节点拖动并释放全局监听器。 */
function endDrag() {
  dragState = undefined;
  window.removeEventListener('pointermove', handleDrag);
}

/** 将视口指针位置转换为不受平移和缩放影响的画布逻辑坐标。 */
function canvasPointFromPointer(
  event: Pick<PointerEvent, 'clientX' | 'clientY'>,
) {
  if (!canvasRef.value) return { x: 0, y: 0 };
  const rect = canvasRef.value.getBoundingClientRect();
  return {
    x: (event.clientX - rect.left - canvasView.x) / canvasView.zoom,
    y: (event.clientY - rect.top - canvasView.y) / canvasView.zoom,
  };
}

/** 在画布空白区域按下时开始平移视口。 */
function beginCanvasPan(event: PointerEvent) {
  if (event.button !== 0 || !canvasRef.value) return;
  const target = event.target as HTMLElement;
  if (
    target.closest(
      '.table-node, .relation-hit, .relation-label, .relation-popover, .canvas-fit-button',
    )
  ) {
    return;
  }
  event.preventDefault();
  clearCanvasSelection();
  canvasPanning.value = true;
  canvasPanState = {
    startClientX: event.clientX,
    startClientY: event.clientY,
    startX: canvasView.x,
    startY: canvasView.y,
  };
  window.addEventListener('pointermove', handleCanvasPan);
  window.addEventListener('pointerup', endCanvasPan, { once: true });
}

/** 按指针位移更新画布视口。 */
function handleCanvasPan(event: PointerEvent) {
  if (!canvasPanState) return;
  canvasView.x =
    canvasPanState.startX + event.clientX - canvasPanState.startClientX;
  canvasView.y =
    canvasPanState.startY + event.clientY - canvasPanState.startClientY;
}

/** 结束画布平移并释放全局监听器。 */
function endCanvasPan() {
  canvasPanState = undefined;
  canvasPanning.value = false;
  window.removeEventListener('pointermove', handleCanvasPan);
  window.removeEventListener('pointerup', endCanvasPan);
}

/** 恢复画布视口的默认平移和缩放。 */
function resetCanvasView() {
  endCanvasPan();
  canvasView.x = 0;
  canvasView.y = 0;
  canvasView.zoom = 1;
}

/** 返回节点当前渲染高度，用于计算适应画布比例。 */
function tableNodeHeight(table: EditorTable) {
  const expandHeight = table.columns.length > DEFAULT_VISIBLE_COLUMNS ? 34 : 0;
  return (
    NODE_HEADER_HEIGHT +
    visibleColumns(table).length * NODE_ROW_HEIGHT +
    expandHeight
  );
}

/** 将全部节点按可读比例缩放并居中到当前可视区域。 */
function fitCanvas() {
  if (!canvasRef.value || editorTables.value.length === 0) return;
  const minX = Math.min(...editorTables.value.map((table) => table.positionX));
  const minY = Math.min(...editorTables.value.map((table) => table.positionY));
  const maxX = Math.max(
    ...editorTables.value.map((table) => table.positionX + NODE_WIDTH),
  );
  const maxY = Math.max(
    ...editorTables.value.map(
      (table) => table.positionY + tableNodeHeight(table),
    ),
  );
  const contentWidth = Math.max(1, maxX - minX);
  const contentHeight = Math.max(1, maxY - minY);
  const viewportWidth = Math.max(
    1,
    canvasRef.value.clientWidth - CANVAS_FIT_PADDING * 2,
  );
  const viewportHeight = Math.max(
    1,
    canvasRef.value.clientHeight - CANVAS_FIT_PADDING * 2,
  );
  const zoom = Math.min(
    MAX_CANVAS_ZOOM,
    Math.max(
      MIN_CANVAS_ZOOM,
      Math.min(viewportWidth / contentWidth, viewportHeight / contentHeight),
    ),
  );
  canvasView.zoom = zoom;
  canvasView.x =
    (canvasRef.value.clientWidth - contentWidth * zoom) / 2 - minX * zoom;
  canvasView.y =
    (canvasRef.value.clientHeight - contentHeight * zoom) / 2 - minY * zoom;
}

/** 查找物理表在当前画布中的节点。 */
function findEditorTable(
  connection: DataspaceConnectionView,
  object: DataspaceObjectView,
) {
  const schemaName = object.schemaName || object.catalogName || 'default';
  const connectionName = connection.name.trim().toLocaleLowerCase();
  return editorTables.value.find((table) => {
    const sameConnection =
      String(table.connectionId) === String(connection.id) ||
      table.connectionName.trim().toLocaleLowerCase() === connectionName;
    const samePath =
      samePhysicalName(connection.databaseType, table.schemaName, schemaName) &&
      samePhysicalName(
        connection.databaseType,
        table.objectName,
        object.objectName,
      );
    return (
      sameConnection &&
      (String(table.objectId) === String(object.id) || samePath)
    );
  });
}

/**
 * 按数据库标识符规则比较物理路径；PostgreSQL 保留引用标识符大小写。
 */
function samePhysicalName(
  databaseType: DataspaceConnectionView['databaseType'] | undefined,
  left: string,
  right: string,
) {
  if (databaseType === 'POSTGRESQL') return left.trim() === right.trim();
  return left.trim().toLocaleLowerCase() === right.trim().toLocaleLowerCase();
}

/** 判断物理表是否已经加入当前画布。 */
function isObjectAdded(
  connection: DataspaceConnectionView,
  object: DataspaceObjectView,
) {
  return Boolean(findEditorTable(connection, object));
}

/** 返回与物理表名不同的逻辑表名，相同时不重复展示。 */
function mappedLogicalName(
  connection: DataspaceConnectionView,
  object: DataspaceObjectView,
) {
  const logicalName = findEditorTable(connection, object)?.tableAlias.trim();
  return logicalName && logicalName !== object.objectName.trim()
    ? logicalName
    : '';
}

/** 生成物理表选项的完整悬浮说明。 */
function tableOptionTitle(
  connection: DataspaceConnectionView,
  object: DataspaceObjectView,
) {
  const logicalName = mappedLogicalName(connection, object);
  return logicalName
    ? `${object.objectName} → ${logicalName}`
    : object.objectName;
}

/** 切换物理表的画布选中状态。 */
function toggleObject(
  connection: DataspaceConnectionView,
  object: DataspaceObjectView,
) {
  const selected = findEditorTable(connection, object);
  if (selected) {
    removeEditorTable(selected.clientKey);
    return;
  }
  addObject(connection, object);
}

/** 返回数据连接分组是否展开，搜索时始终展示匹配对象。 */
function isConnectionExpanded(connectionId: DataspaceId) {
  return (
    Boolean(objectKeyword.value.trim()) ||
    !collapsedConnectionIds.value.has(String(connectionId))
  );
}

/** 切换数据连接分组的展开状态。 */
function toggleConnection(connectionId: DataspaceId) {
  const key = String(connectionId);
  const next = new Set(collapsedConnectionIds.value);
  if (next.has(key)) next.delete(key);
  else next.add(key);
  collapsedConnectionIds.value = next;
}

/** 生成连接内唯一的数据库分组键。 */
function schemaKey(connectionId: DataspaceId, schemaName: string) {
  return `${connectionId}:${schemaName}`;
}

/** 返回数据库分组是否展开，搜索时始终展示匹配表。 */
function isSchemaExpanded(connectionId: DataspaceId, schemaName: string) {
  return (
    Boolean(objectKeyword.value.trim()) ||
    !collapsedSchemaKeys.value.has(schemaKey(connectionId, schemaName))
  );
}

/** 切换数据库分组的展开状态。 */
function toggleSchema(connectionId: DataspaceId, schemaName: string) {
  const key = schemaKey(connectionId, schemaName);
  const next = new Set(collapsedSchemaKeys.value);
  if (next.has(key)) next.delete(key);
  else next.add(key);
  collapsedSchemaKeys.value = next;
}

/** 记录从物理表面板发起的拖动。 */
function beginObjectDrag(
  event: DragEvent,
  connection: DataspaceConnectionView,
  object: DataspaceObjectView,
) {
  if (isObjectAdded(connection, object)) {
    event.preventDefault();
    return;
  }
  draggedObject = { connection, object };
  if (event.dataTransfer) {
    event.dataTransfer.effectAllowed = 'copy';
    event.dataTransfer.setData('text/plain', object.objectName);
  }
}

/** 清理物理表拖动状态。 */
function endObjectDrag() {
  draggedObject = undefined;
}

/** 将拖动的物理表放置到画布指针位置。 */
function dropObject(event: DragEvent) {
  if (!draggedObject || !canvasRef.value) return;
  const point = canvasPointFromPointer(event);
  addObject(draggedObject.connection, draggedObject.object, {
    x: point.x - NODE_WIDTH / 2,
    y: point.y - NODE_HEADER_HEIGHT / 2,
  });
  endObjectDrag();
}

/** 返回表中参与关系的字段，折叠时这些字段始终可见。 */
function linkedColumnNames(table: EditorTable) {
  const names = new Set<string>();
  for (const relation of editorRelations.value) {
    if (relation.leftClientKey === table.clientKey) {
      names.add(relation.leftColumn);
    }
    if (relation.rightClientKey === table.clientKey) {
      names.add(relation.rightColumn);
    }
  }
  return names;
}

/** 返回节点当前需要展示的字段。 */
function visibleColumns(table: EditorTable) {
  if (
    expandedTableKeys.value.has(table.clientKey) ||
    table.columns.length <= DEFAULT_VISIBLE_COLUMNS
  ) {
    return table.columns;
  }
  const linked = linkedColumnNames(table);
  return table.columns.filter(
    (column, index) =>
      index < DEFAULT_VISIBLE_COLUMNS || linked.has(column.name),
  );
}

/** 返回节点当前省略的字段数量。 */
function hiddenColumnCount(table: EditorTable) {
  return Math.max(0, table.columns.length - visibleColumns(table).length);
}

/** 切换节点字段的展开状态。 */
function toggleTableColumns(clientKey: string) {
  const next = new Set(expandedTableKeys.value);
  if (next.has(clientKey)) next.delete(clientKey);
  else next.add(clientKey);
  expandedTableKeys.value = next;
}

/** 返回字段端点在画布中的坐标。 */
function endpointPoint(
  table: EditorTable,
  columnName: string,
  side: LinkEndpoint['side'],
): CanvasPoint {
  const rowIndex = Math.max(
    0,
    visibleColumns(table).findIndex((column) => column.name === columnName),
  );
  return {
    x: table.positionX + (side === 'right' ? NODE_WIDTH : 0),
    y:
      table.positionY +
      NODE_HEADER_HEIGHT +
      rowIndex * NODE_ROW_HEIGHT +
      NODE_ROW_HEIGHT / 2,
  };
}

/** 生成适合字段连线的平滑曲线路径。 */
function curvePath(start: CanvasPoint, end: CanvasPoint) {
  const control = Math.max(56, Math.abs(end.x - start.x) * 0.45);
  const direction = end.x >= start.x ? 1 : -1;
  return `M ${start.x} ${start.y} C ${start.x + control * direction} ${start.y}, ${end.x - control * direction} ${end.y}, ${end.x} ${end.y}`;
}

/** 返回持久化关系的路径和中心点。 */
function relationGeometry(relation: EditorRelation) {
  const left = editorTables.value.find(
    (table) => table.clientKey === relation.leftClientKey,
  );
  const right = editorTables.value.find(
    (table) => table.clientKey === relation.rightClientKey,
  );
  if (!left || !right) return undefined;
  const leftFirst = left.positionX <= right.positionX;
  const start = endpointPoint(
    left,
    relation.leftColumn,
    leftFirst ? 'right' : 'left',
  );
  const end = endpointPoint(
    right,
    relation.rightColumn,
    leftFirst ? 'left' : 'right',
  );
  return {
    end,
    midX: (start.x + end.x) / 2,
    midY: (start.y + end.y) / 2,
    path: curvePath(start, end),
    start,
  };
}

/** 返回关系标签的画布定位样式。 */
function relationLabelStyle(relation: EditorRelation) {
  const geometry = relationGeometry(relation);
  return geometry
    ? { left: `${geometry.midX}px`, top: `${geometry.midY}px` }
    : undefined;
}

/** 返回字段端点是否为当前连线起点。 */
function isLinkStart(
  table: EditorTable,
  column: DataspaceColumn,
  side: LinkEndpoint['side'],
) {
  return (
    linkStart.value?.clientKey === table.clientKey &&
    linkStart.value.column === column.name &&
    linkStart.value.side === side
  );
}

/** 从字段端点开始拖拽连线。 */
function beginLink(
  event: PointerEvent,
  table: EditorTable,
  column: DataspaceColumn,
  side: LinkEndpoint['side'],
) {
  event.preventDefault();
  event.stopPropagation();
  linkStart.value = {
    clientKey: table.clientKey,
    column: column.name,
    side,
  };
  Object.assign(linkPointer, endpointPoint(table, column.name, side));
  window.addEventListener('pointermove', moveLink);
  window.addEventListener('pointerup', cancelLink, { once: true });
}

/** 更新正在创建的关系预览线。 */
function moveLink(event: PointerEvent) {
  if (!linkStart.value || !canvasRef.value) return;
  Object.assign(linkPointer, canvasPointFromPointer(event));
}

/** 取消当前字段连线。 */
function cancelLink() {
  linkStart.value = undefined;
  window.removeEventListener('pointermove', moveLink);
  window.removeEventListener('pointerup', cancelLink);
}

/** 清理画布上的临时选择和连线状态。 */
function clearCanvasSelection() {
  selectedRelationKey.value = undefined;
  cancelLink();
}

/** 使用第二个字段端点完成关系创建。 */
function finishLink(
  event: Event | undefined,
  table: EditorTable,
  column: DataspaceColumn,
) {
  event?.preventDefault();
  event?.stopPropagation();
  const start = linkStart.value;
  if (!start) return;
  cancelLink();
  if (start.clientKey === table.clientKey) {
    if (start.column !== column.name) {
      ElMessage.warning('请选择另一张表的字段');
    }
    return;
  }
  const duplicate = editorRelations.value.find(
    (relation) =>
      (relation.leftClientKey === start.clientKey &&
        relation.leftColumn === start.column &&
        relation.rightClientKey === table.clientKey &&
        relation.rightColumn === column.name) ||
      (relation.leftClientKey === table.clientKey &&
        relation.leftColumn === column.name &&
        relation.rightClientKey === start.clientKey &&
        relation.rightColumn === start.column),
  );
  if (duplicate) {
    selectedRelationKey.value = duplicate.key;
    ElMessage.info('该字段关系已存在');
    return;
  }
  const relation: EditorRelation = {
    issueCode: undefined,
    issueMessage: undefined,
    joinType: 'INNER',
    key: `relation:${Date.now()}:${editorRelations.value.length}`,
    leftClientKey: start.clientKey,
    leftColumn: start.column,
    rightClientKey: table.clientKey,
    rightColumn: column.name,
    status: 'ACTIVE',
  };
  editorRelations.value.push(relation);
  selectedRelationKey.value = relation.key;
}

/** 支持键盘依次选择两个字段端点建立关系。 */
function useKeyboardEndpoint(
  event: KeyboardEvent,
  table: EditorTable,
  column: DataspaceColumn,
  side: LinkEndpoint['side'],
) {
  if (!linkStart.value) {
    linkStart.value = {
      clientKey: table.clientKey,
      column: column.name,
      side,
    };
    Object.assign(linkPointer, endpointPoint(table, column.name, side));
    return;
  }
  finishLink(event, table, column);
}

/** 更新关系的 Join 类型。 */
function setJoinType(relation: EditorRelation, joinType: string) {
  relation.joinType = joinType;
}

/** 返回关系端点的简洁说明。 */
function relationCaption(relation: EditorRelation) {
  const left = editorTables.value.find(
    (table) => table.clientKey === relation.leftClientKey,
  );
  const right = editorTables.value.find(
    (table) => table.clientKey === relation.rightClientKey,
  );
  return `${left?.tableAlias || '-'}.${relation.leftColumn} → ${right?.tableAlias || '-'}.${relation.rightColumn}`;
}

/** 删除当前关系草稿。 */
function removeRelation(relation: EditorRelation) {
  editorRelations.value = editorRelations.value.filter(
    (item) => item.key !== relation.key,
  );
  if (selectedRelationKey.value === relation.key) {
    selectedRelationKey.value = undefined;
  }
}

/** 保存数据空间不可变 revision。 */
async function saveEditor() {
  editorError.value = '';
  if (!editor.name.trim()) {
    ElMessage.warning('请输入数据空间名称');
    return;
  }
  if (editorTables.value.length === 0) {
    ElMessage.warning('请至少加入一张物理表');
    return;
  }
  const unavailableTable = editorTables.value.find(
    (table) => table.status !== 'ACTIVE',
  );
  if (unavailableTable) {
    editorError.value =
      unavailableTable.issueMessage ||
      `表“${unavailableTable.objectName}”当前不可用，请移除或恢复后再保存`;
    return;
  }
  const invalidRelation = editorRelations.value.find(
    (relation) => relation.status !== 'ACTIVE',
  );
  if (invalidRelation) {
    selectedRelationKey.value = invalidRelation.key;
    editorError.value =
      invalidRelation.issueMessage || '存在失效关系，请重新连线或删除后保存';
    return;
  }
  for (const table of editorTables.value) {
    const error = logicalTableNameError(
      table.tableAlias,
      editorTables.value
        .filter((item) => item.clientKey !== table.clientKey)
        .map((item) => item.tableAlias),
    );
    if (error) {
      editorError.value = error;
      return;
    }
  }
  saving.value = true;
  try {
    const saved = await saveDataspace({
      description: editor.description.trim(),
      expectedRevision: editor.id ? editor.revision : undefined,
      id: editor.id,
      name: editor.name.trim(),
      relations: editorRelations.value.map((relation) => ({
        joinType: relation.joinType,
        leftClientKey: relation.leftClientKey,
        leftColumn: relation.leftColumn,
        rightClientKey: relation.rightClientKey,
        rightColumn: relation.rightColumn,
      })),
      tables: editorTables.value.map((table) => ({
        clientKey: table.clientKey,
        objectId: table.objectId,
        positionX: Math.round(table.positionX),
        positionY: Math.round(table.positionY),
        schemaAlias: table.schemaAlias,
        sourceAlias: table.sourceAlias,
        tableAlias: table.tableAlias,
      })),
    });
    editor.id = saved.id;
    editor.revision = saved.currentRevision;
    ElMessage.success('数据空间已保存');
    emit('changed');
    await loadSpaces();
    mode.value = 'list';
  } catch (error) {
    editorError.value = dataspaceErrorMessage(error, '数据空间保存失败');
  } finally {
    saving.value = false;
  }
}

/** 删除指定数据空间。 */
async function removeSpace(current: DataspaceSummary) {
  try {
    await ElMessageBox.confirm(`确认删除“${current.name}”？`, '删除数据空间', {
      confirmButtonText: '删除',
      cancelButtonText: '取消',
      type: 'warning',
    });
  } catch (error) {
    if (error === 'cancel' || error === 'close') return;
    throw error;
  }
  await removeDataspace(current.id);
  await loadSpaces();
  emit('changed');
  ElMessage.success('数据空间已删除');
}

/** 切换指定数据空间的查询可用状态。 */
async function toggleSpace(current: DataspaceSummary) {
  const enabled = current.status !== 'ENABLED';
  statusChangingId.value = current.id;
  try {
    await setDataspaceEnabled(current.id, enabled);
    await loadSpaces();
    emit('changed');
    ElMessage.success(enabled ? '数据空间已启用' : '数据空间已禁用');
  } finally {
    statusChangingId.value = undefined;
  }
}

/** 返回数据库品牌图标。 */
function connectionIcon(databaseType: string) {
  return databaseType === 'POSTGRESQL' ? postgresqlIcon : mysqlIcon;
}

onMounted(() => void loadSpaces());
onBeforeUnmount(() => {
  listEpoch += 1;
  endDrag();
  endCanvasPan();
  cancelLink();
  endObjectDrag();
});
</script>

<template>
  <section v-if="mode === 'list'" class="space-list-panel">
    <div class="space-list-main">
      <header class="space-toolbar">
        <div class="space-toolbar-main">
          <slot name="workspace-navigation" :dataspace-id="undefined"></slot>
          <ElInput v-model="keyword" clearable placeholder="搜索数据空间">
            <template #prefix><Search /></template>
          </ElInput>
        </div>
        <ElButton type="primary" @click="createSpace">
          <Plus />
          新建数据空间
        </ElButton>
      </header>
      <div v-loading="loading" class="space-rows" role="list">
        <article
          v-for="row in filteredSpaces"
          :key="String(row.id)"
          class="space-row"
          role="listitem"
        >
          <button
            class="space-row-main"
            type="button"
            @click="editSpace(row.id)"
          >
            <span class="space-mark"><Database /></span>
            <strong>{{ row.name }}</strong>
            <span class="space-counts">
              <span><Table2 />{{ row.tableCount }} 张表</span>
              <span><Database />{{ row.sourceCount }} 个连接</span>
            </span>
            <span class="space-status">
              <span
                class="status-dot"
                :class="{ disabled: row.status !== 'ENABLED' }"
              ></span>
              {{ row.status === 'ENABLED' ? '可用' : '已禁用' }}
            </span>
            <time>
              {{ row.modified ? new Date(row.modified).toLocaleString() : '-' }}
            </time>
          </button>
          <div class="space-row-actions">
            <ElButton
              text
              title="SQL 工作台"
              aria-label="在 SQL 工作台中打开"
              :disabled="row.status !== 'ENABLED'"
              @click="emit('openWorkbench', row.id)"
            >
              <SquareTerminal />
            </ElButton>
            <ElButton
              text
              :title="row.status === 'ENABLED' ? '禁用' : '启用'"
              :aria-label="
                row.status === 'ENABLED' ? '禁用数据空间' : '启用数据空间'
              "
              :loading="String(statusChangingId) === String(row.id)"
              :disabled="
                statusChangingId !== undefined &&
                String(statusChangingId) !== String(row.id)
              "
              @click="toggleSpace(row)"
            >
              <EyeOff v-if="row.status === 'ENABLED'" />
              <Eye v-else />
            </ElButton>
            <ElButton
              text
              type="danger"
              title="删除"
              aria-label="删除数据空间"
              @click="removeSpace(row)"
            >
              <Trash2 />
            </ElButton>
          </div>
        </article>
        <ElEmpty
          v-if="!loading && filteredSpaces.length === 0"
          description="还没有数据空间"
        />
      </div>
    </div>
  </section>

  <section v-else class="space-editor">
    <header class="editor-header">
      <slot name="workspace-navigation" :dataspace-id="editor.id"></slot>
      <ElButton
        text
        circle
        class="editor-back"
        aria-label="返回数据空间列表"
        @click="mode = 'list'"
      >
        <ChevronLeft />
      </ElButton>
      <div class="editor-name">
        <ElInput
          v-model="editor.name"
          maxlength="64"
          placeholder="数据空间名称"
        />
        <ElInput
          v-model="editor.description"
          maxlength="240"
          placeholder="添加描述（可选）"
        />
      </div>
      <ElButton type="primary" :loading="saving" @click="saveEditor">
        保存
      </ElButton>
    </header>

    <div v-if="editorError" class="editor-error" role="alert">
      <CircleAlert />
      <span>{{ editorError }}</span>
    </div>

    <div class="editor-body">
      <aside class="source-palette">
        <div class="palette-title">
          <strong>物理表</strong>
          <ElTag effect="plain">{{ editorTables.length }}</ElTag>
        </div>
        <ElInput v-model="objectKeyword" clearable placeholder="搜索表和字段">
          <template #prefix><Search /></template>
        </ElInput>
        <div class="source-tree">
          <section
            v-for="group in sourceGroups"
            :key="String(group.connection.id)"
          >
            <button
              type="button"
              class="connection-toggle"
              :aria-expanded="isConnectionExpanded(group.connection.id)"
              @click="toggleConnection(group.connection.id)"
            >
              <ChevronDown
                v-if="isConnectionExpanded(group.connection.id)"
                class="connection-chevron"
              />
              <ChevronRight v-else class="connection-chevron" />
              <img
                :src="connectionIcon(group.connection.databaseType)"
                alt=""
              />
              <strong>{{ group.connection.name }}</strong>
              <small>{{
                group.connection.databaseType === 'POSTGRESQL'
                  ? 'PostgreSQL'
                  : 'MySQL'
              }}</small>
            </button>
            <div v-if="isConnectionExpanded(group.connection.id)">
              <div
                v-for="schema in group.schemas"
                :key="schema.name"
                class="palette-schema"
              >
                <button
                  class="schema-toggle"
                  type="button"
                  :aria-expanded="
                    isSchemaExpanded(group.connection.id, schema.name)
                  "
                  @click="toggleSchema(group.connection.id, schema.name)"
                >
                  <ChevronDown
                    v-if="isSchemaExpanded(group.connection.id, schema.name)"
                    class="schema-chevron"
                  />
                  <ChevronRight v-else class="schema-chevron" />
                  <Database class="schema-icon" />
                  <span>{{ schema.name }}</span>
                </button>
                <div
                  v-if="isSchemaExpanded(group.connection.id, schema.name)"
                  class="schema-objects"
                >
                  <button
                    v-for="object in schema.objects"
                    :key="String(object.id)"
                    class="table-option"
                    :class="{
                      selected: isObjectAdded(group.connection, object),
                    }"
                    type="button"
                    :aria-pressed="isObjectAdded(group.connection, object)"
                    :draggable="!isObjectAdded(group.connection, object)"
                    :title="tableOptionTitle(group.connection, object)"
                    @click="toggleObject(group.connection, object)"
                    @dragend="endObjectDrag"
                    @dragstart="
                      beginObjectDrag($event, group.connection, object)
                    "
                  >
                    <Table2 class="table-icon" />
                    <span class="table-option-copy">
                      <span class="table-physical-name">{{
                        object.objectName
                      }}</span>
                      <small
                        v-if="mappedLogicalName(group.connection, object)"
                        class="table-logical-name"
                      >
                        <ChevronRight />
                        <span>{{
                          mappedLogicalName(group.connection, object)
                        }}</span>
                      </small>
                    </span>
                  </button>
                </div>
              </div>
            </div>
          </section>
          <ElEmpty
            v-if="sourceGroups.length === 0"
            description="请先创建数据连接"
            :image-size="64"
          />
        </div>
      </aside>

      <main class="model-canvas-wrap">
        <div class="canvas-toolbar">
          <strong>{{ editor.name || '未命名数据空间' }}</strong>
          <span
            >{{ editorTables.length }} 张表 ·
            {{ editorRelations.length }} 条关系</span
          >
        </div>
        <div
          ref="canvasRef"
          class="model-canvas"
          :class="{ panning: canvasPanning }"
          :style="canvasViewportStyle"
          @dragover.prevent
          @drop.prevent="dropObject"
          @pointerdown="beginCanvasPan"
        >
          <div class="canvas-scene" :style="canvasSceneStyle">
            <svg class="relation-layer">
              <template v-for="relation in editorRelations" :key="relation.key">
                <path
                  v-if="relationGeometry(relation)"
                  class="relation-path"
                  :class="{
                    active: selectedRelationKey === relation.key,
                    invalid: relation.status !== 'ACTIVE',
                  }"
                  :d="relationGeometry(relation)?.path"
                />
                <path
                  v-if="relationGeometry(relation)"
                  class="relation-hit"
                  :d="relationGeometry(relation)?.path"
                  role="button"
                  tabindex="0"
                  :aria-label="`编辑关系 ${relationCaption(relation)}`"
                  @click.stop="selectedRelationKey = relation.key"
                  @keydown.enter.prevent="selectedRelationKey = relation.key"
                />
              </template>
              <path
                v-if="previewPath"
                class="relation-preview"
                :d="previewPath"
              />
            </svg>
            <template
              v-for="relation in editorRelations"
              :key="`${relation.key}:label`"
            >
              <button
                v-if="relationGeometry(relation)"
                class="relation-label"
                :class="{
                  active: selectedRelationKey === relation.key,
                  invalid: relation.status !== 'ACTIVE',
                }"
                :style="relationLabelStyle(relation)"
                :title="relation.issueMessage"
                type="button"
                :aria-label="`配置 ${relationCaption(relation)}`"
                @click.stop="selectedRelationKey = relation.key"
              >
                {{ relation.joinType }}
              </button>
            </template>
            <div
              v-if="selectedRelation && relationGeometry(selectedRelation)"
              class="relation-popover"
              :style="relationLabelStyle(selectedRelation)"
              @pointerdown.stop
            >
              <span
                v-if="selectedRelation.status !== 'ACTIVE'"
                class="relation-issue"
              >
                <CircleAlert />
                {{ selectedRelation.issueMessage || '关系已失效' }}
              </span>
              <div class="join-options" aria-label="Join 类型">
                <button
                  v-for="type in ['INNER', 'LEFT', 'RIGHT', 'FULL']"
                  :key="type"
                  :class="{ active: selectedRelation.joinType === type }"
                  type="button"
                  @click="setJoinType(selectedRelation, type)"
                >
                  {{ type }}
                </button>
              </div>
              <button
                class="relation-delete"
                type="button"
                aria-label="删除关系"
                title="删除关系"
                @click="removeRelation(selectedRelation)"
              >
                <Trash2 />
              </button>
            </div>
            <article
              v-for="table in editorTables"
              :key="table.clientKey"
              class="table-node"
              :class="{ unavailable: table.status !== 'ACTIVE' }"
              :title="table.issueMessage"
              :style="{
                transform: `translate(${table.positionX}px, ${table.positionY}px)`,
              }"
            >
              <header @pointerdown="beginDrag($event, table)">
                <span class="node-database-icon">
                  <img :src="connectionIcon(table.databaseType)" alt="" />
                </span>
                <span class="node-title">
                  <span
                    v-if="editingTableKey === table.clientKey"
                    class="node-name-editor"
                  >
                    <input
                      v-model="logicalNameDraft"
                      :data-logical-name-editor="table.clientKey"
                      maxlength="64"
                      aria-label="逻辑表名"
                      @blur="commitLogicalNameEdit(table)"
                      @keydown.enter.prevent="
                        ($event.target as HTMLInputElement).blur()
                      "
                      @keydown.esc.prevent="cancelLogicalNameEdit"
                      @pointerdown.stop
                    />
                  </span>
                  <button
                    v-else
                    class="node-name-button"
                    type="button"
                    :title="`修改逻辑表名 ${table.tableAlias}`"
                    @click="beginLogicalNameEdit(table)"
                    @pointerdown.stop
                  >
                    <strong>{{ table.tableAlias }}</strong>
                    <Pencil />
                  </button>
                  <small>
                    {{ table.objectName }} · {{ table.connectionName }} ·
                    {{ table.schemaName }} · {{ table.columns.length }} 字段
                  </small>
                </span>
                <CircleAlert
                  v-if="table.status !== 'ACTIVE'"
                  class="node-warning"
                />
                <button
                  type="button"
                  aria-label="移除表"
                  @pointerdown.stop
                  @click="removeEditorTable(table.clientKey)"
                >
                  <X />
                </button>
              </header>
              <div class="node-columns">
                <div
                  v-for="column in visibleColumns(table)"
                  :key="column.name"
                  class="node-column"
                >
                  <button
                    class="column-handle left"
                    :class="{
                      active: isLinkStart(table, column, 'left'),
                      connecting: Boolean(linkStart),
                    }"
                    type="button"
                    :disabled="table.status !== 'ACTIVE'"
                    :aria-label="`从 ${table.tableAlias}.${column.name} 建立关系`"
                    @keydown.enter.prevent.stop="
                      useKeyboardEndpoint($event, table, column, 'left')
                    "
                    @keydown.space.prevent.stop="
                      useKeyboardEndpoint($event, table, column, 'left')
                    "
                    @keydown.esc.prevent.stop="cancelLink"
                    @pointerdown="beginLink($event, table, column, 'left')"
                    @pointerup="finishLink($event, table, column)"
                  ></button>
                  <KeyRound v-if="column.primaryKey" class="primary-key-icon" />
                  <Columns3 v-else />
                  <span :title="column.name">{{ column.name }}</span>
                  <small :title="column.typeName">{{ column.typeName }}</small>
                  <button
                    class="column-handle right"
                    :class="{
                      active: isLinkStart(table, column, 'right'),
                      connecting: Boolean(linkStart),
                    }"
                    type="button"
                    :disabled="table.status !== 'ACTIVE'"
                    :aria-label="`从 ${table.tableAlias}.${column.name} 建立关系`"
                    @keydown.enter.prevent.stop="
                      useKeyboardEndpoint($event, table, column, 'right')
                    "
                    @keydown.space.prevent.stop="
                      useKeyboardEndpoint($event, table, column, 'right')
                    "
                    @keydown.esc.prevent.stop="cancelLink"
                    @pointerdown="beginLink($event, table, column, 'right')"
                    @pointerup="finishLink($event, table, column)"
                  ></button>
                </div>
              </div>
              <button
                v-if="table.columns.length > DEFAULT_VISIBLE_COLUMNS"
                class="node-expand"
                type="button"
                @pointerdown.stop
                @click="toggleTableColumns(table.clientKey)"
              >
                <template v-if="expandedTableKeys.has(table.clientKey)">
                  <ChevronUp /> 收起字段
                </template>
                <template v-else>
                  <ChevronDown /> 展开 {{ hiddenColumnCount(table) }} 个字段
                </template>
              </button>
            </article>
          </div>
          <button
            class="canvas-fit-button"
            type="button"
            :disabled="editorTables.length === 0"
            aria-label="适应画布"
            title="适应画布"
            @pointerdown.stop
            @click="fitCanvas"
          >
            <Maximize />
          </button>
          <ElEmpty
            v-if="editorTables.length === 0"
            description="点击或拖入左侧物理表"
          />
        </div>
      </main>
    </div>
  </section>
</template>

<style scoped>
.space-list-panel {
  height: 100%;
  min-height: 0;
  overflow: auto;
  background: hsl(var(--surface-panel));
  font-size: 13px;
}

.space-list-main {
  min-width: 0;
  padding: 0 var(--dataspace-toolbar-padding-inline) var(--space-4);
}

.space-toolbar,
.space-toolbar-main,
.editor-header,
.palette-title,
.source-palette section > header,
.palette-schema .schema-toggle,
.palette-schema .table-option,
.canvas-toolbar,
.table-node header,
.node-columns > div,
.node-expand,
.relation-popover,
.join-options {
  display: flex;
  align-items: center;
}

.space-toolbar {
  min-height: var(--dataspace-toolbar-height);
  justify-content: space-between;
  gap: var(--space-3);
  margin-bottom: var(--space-3);
}

.space-toolbar-main {
  min-width: 0;
  gap: var(--space-3);
}

.space-toolbar-main :deep(.el-input) {
  width: 280px;
}

.space-rows {
  min-height: 120px;
  border-top: 1px solid hsl(var(--divider-faint));
}

.space-row {
  display: grid;
  min-height: 64px;
  align-items: center;
  grid-template-columns: minmax(0, 1fr) auto;
  padding: var(--space-1) var(--space-2);
  border-bottom: 1px solid hsl(var(--divider-faint));
  transition: background-color var(--motion-duration-fast)
    var(--motion-ease-standard);
}

.space-row:hover {
  background: hsl(var(--table-row-hover));
}

.space-row-main {
  display: grid;
  min-width: 0;
  min-height: 56px;
  align-items: center;
  grid-template-columns: 32px minmax(160px, 1fr) 210px 88px 148px;
  gap: var(--space-4);
  padding: 0;
  border: 0;
  background: transparent;
  color: hsl(var(--foreground));
  cursor: pointer;
  text-align: left;
}

.space-row-main:focus-visible {
  border-radius: var(--radius-control);
  outline: 2px solid hsl(var(--primary) / 0.62);
  outline-offset: 2px;
}

.space-mark {
  display: inline-flex;
  width: 32px;
  height: 32px;
  align-items: center;
  justify-content: center;
  border-radius: var(--radius-control);
  background: hsl(var(--nav-item-active));
  color: hsl(var(--nav-item-active-foreground));
}

.space-mark svg {
  width: 16px;
  height: 16px;
}

.space-row-main > strong {
  min-width: 0;
  overflow: hidden;
  font-size: 14px;
  font-weight: 600;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.space-counts,
.space-counts span,
.space-status,
.space-row-actions {
  display: flex;
  align-items: center;
}

.space-counts {
  gap: var(--space-4);
}

.space-counts span,
.space-row-main time {
  color: hsl(var(--text-muted));
  font-size: 12px;
}

.space-counts span {
  gap: var(--space-1);
}

.space-counts svg {
  width: 14px;
  height: 14px;
}

.space-status {
  color: hsl(var(--text-muted));
  font-size: 12px;
}

.status-dot {
  display: inline-block;
  width: 7px;
  height: 7px;
  margin-right: 6px;
  border-radius: 50%;
  background: hsl(var(--success));
}

.status-dot.disabled {
  background: hsl(var(--text-muted));
}

.space-row-actions {
  gap: 0;
  opacity: 0.68;
  transition: opacity var(--motion-duration-fast) var(--motion-ease-standard);
}

.space-row:hover .space-row-actions,
.space-row:focus-within .space-row-actions {
  opacity: 1;
}

.space-row-actions :deep(.el-button) {
  width: 32px;
  height: 32px;
  padding: 0;
}

.space-row-actions :deep(.el-button + .el-button) {
  margin-left: 0;
}

.space-row-actions :deep(svg) {
  width: 16px;
  height: 16px;
}

.space-editor {
  display: flex;
  height: 100%;
  min-height: 0;
  flex-direction: column;
  font-size: 13px;
}

.editor-header {
  min-height: var(--dataspace-toolbar-height);
  gap: var(--space-2);
  padding: 0 var(--dataspace-toolbar-padding-inline);
  box-shadow: inset 0 -1px 0 hsl(var(--line-subtle));
}

.editor-back {
  width: 32px;
  height: 32px;
  flex: 0 0 auto;
  color: hsl(var(--text-secondary));
}

.editor-back :deep(svg) {
  width: 17px;
  height: 17px;
  stroke-width: 1.5;
}

.editor-error {
  display: flex;
  min-height: 36px;
  align-items: center;
  gap: var(--space-2);
  padding: var(--space-2) var(--space-4);
  border-bottom: 1px solid hsl(var(--destructive) / 0.18);
  background: hsl(var(--destructive) / 0.06);
  color: hsl(var(--destructive));
  font-size: 12px;
}

.editor-error svg {
  width: 15px;
  height: 15px;
  flex: 0 0 auto;
}

.editor-name {
  display: flex;
  min-width: 0;
  flex: 1;
  gap: 8px;
}

.editor-name :deep(.el-input:first-child) {
  max-width: 240px;
}

.editor-body {
  display: grid;
  min-height: 0;
  flex: 1;
  grid-template-columns: 272px minmax(560px, 1fr);
}

.source-palette {
  display: flex;
  min-width: 0;
  min-height: 0;
  flex-direction: column;
  background: hsl(var(--surface-panel));
}

.source-palette {
  border-right: 1px solid hsl(var(--line-subtle));
}

.palette-title {
  min-height: 48px;
  justify-content: space-between;
  padding: 0 var(--space-3);
}

.source-palette > :deep(.el-input) {
  width: auto;
  margin: 0 var(--space-3) var(--space-2);
}

.source-tree {
  min-height: 0;
  flex: 1;
  overflow: auto;
  padding: 0 8px 16px;
}

.connection-toggle {
  display: flex;
  width: 100%;
  align-items: center;
  gap: var(--space-2);
  min-height: 38px;
  padding: 0 var(--space-2);
  border: 0;
  border-radius: var(--radius-md);
  background: transparent;
  color: hsl(var(--foreground));
  cursor: pointer;
  text-align: left;
  transition: background-color var(--motion-duration-fast)
    var(--motion-ease-standard);
}

.connection-toggle:hover {
  background: hsl(var(--nav-item-hover));
}

.connection-toggle:focus-visible {
  outline: 2px solid hsl(var(--primary) / 0.45);
  outline-offset: -2px;
}

.connection-toggle img,
.table-node header img {
  width: 20px;
  height: 20px;
  object-fit: contain;
}

.connection-toggle small {
  margin-left: auto;
  color: hsl(var(--text-muted));
}

.connection-chevron {
  width: 13px;
  height: 13px;
  flex: 0 0 auto;
  color: hsl(var(--text-muted));
  stroke-width: 1.6;
}

.palette-schema .schema-toggle,
.palette-schema .table-option {
  width: 100%;
  min-height: 34px;
  gap: var(--space-2);
  border: 0;
  border-radius: var(--radius-md);
  background: transparent;
  text-align: left;
  transition:
    background-color var(--motion-duration-fast) var(--motion-ease-standard),
    color var(--motion-duration-fast) var(--motion-ease-standard);
}

.palette-schema .schema-toggle {
  padding: 0 12px;
  color: hsl(var(--foreground));
  font-weight: 500;
  cursor: pointer;
}

.palette-schema .schema-chevron {
  width: 13px;
  height: 13px;
  flex: 0 0 auto;
  color: hsl(var(--text-muted));
  stroke-width: 1.6;
}

.palette-schema .schema-icon {
  width: 16px;
  height: 16px;
  flex: 0 0 auto;
  color: hsl(var(--primary) / 0.72);
  stroke-width: 1.5;
}

.palette-schema .table-option {
  padding: 4px 12px 4px 36px;
  color: hsl(var(--foreground));
  cursor: pointer;
}

.palette-schema .schema-toggle:hover,
.palette-schema .table-option:hover {
  background: hsl(var(--nav-item-hover));
}

.palette-schema .schema-toggle:focus-visible,
.palette-schema .table-option:focus-visible {
  outline: 2px solid hsl(var(--primary) / 0.45);
  outline-offset: -2px;
}

.palette-schema .schema-toggle > span,
.palette-schema .table-option > span {
  flex: 1;
  overflow: hidden;
  text-align: left;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.table-option-copy {
  display: flex;
  min-width: 0;
  flex-direction: column;
  gap: 1px;
}

.table-physical-name {
  overflow: hidden;
  line-height: 18px;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.table-logical-name {
  display: flex;
  min-width: 0;
  align-items: center;
  gap: var(--space-1);
  overflow: hidden;
  color: hsl(var(--text-muted));
  font-size: 10px;
  line-height: 14px;
}

.table-logical-name svg {
  width: 11px;
  height: 11px;
  flex: 0 0 auto;
  stroke-width: 1.5;
}

.table-logical-name span {
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.palette-schema .table-icon {
  width: 15px;
  height: 15px;
  flex: 0 0 auto;
  color: hsl(var(--text-muted) / 0.78);
  stroke-width: 1.4;
}

.palette-schema .table-option.selected {
  background: hsl(var(--primary) / 0.08);
  color: hsl(var(--primary));
}

.palette-schema .table-option.selected .table-icon {
  color: hsl(var(--primary) / 0.82);
}

.palette-schema .table-option.selected .table-logical-name {
  color: hsl(var(--primary) / 0.72);
}

.model-canvas-wrap {
  display: flex;
  min-width: 0;
  min-height: 0;
  flex-direction: column;
}

.canvas-toolbar {
  min-height: 48px;
  gap: var(--space-2);
  padding: 0 var(--space-3);
  border-bottom: 1px solid hsl(var(--line-subtle));
}

.canvas-toolbar span {
  color: hsl(var(--text-muted));
  font-size: 12px;
}

.model-canvas {
  position: relative;
  min-height: 0;
  flex: 1;
  overflow: hidden;
  background-color: hsl(var(--surface-subtle));
  background-image: radial-gradient(
    hsl(var(--line-subtle)) 1px,
    transparent 1px
  );
  background-size: 16px 16px;
  cursor: grab;
  touch-action: none;
}

.model-canvas.panning {
  cursor: grabbing;
  user-select: none;
}

.canvas-scene {
  position: absolute;
  z-index: 0;
  top: 0;
  left: 0;
  transform-origin: 0 0;
  transition: transform var(--motion-duration-medium)
    var(--motion-ease-standard);
  will-change: transform;
}

.model-canvas.panning .canvas-scene {
  transition: none;
}

.canvas-fit-button {
  position: absolute;
  z-index: 8;
  bottom: var(--space-4);
  left: 50%;
  display: inline-flex;
  width: 34px;
  height: 34px;
  align-items: center;
  justify-content: center;
  padding: 0;
  transform: translateX(-50%);
  border: 1px solid hsl(var(--line-subtle));
  border-radius: var(--radius-pill);
  background: hsl(var(--surface-elevated));
  box-shadow: var(--shadow-toolbar);
  color: hsl(var(--text-muted));
  cursor: pointer;
  transition:
    border-color var(--motion-duration-fast) var(--motion-ease-standard),
    background-color var(--motion-duration-fast) var(--motion-ease-standard),
    color var(--motion-duration-fast) var(--motion-ease-standard);
}

.canvas-fit-button:hover:not(:disabled) {
  border-color: hsl(var(--primary) / 0.36);
  background: hsl(var(--surface-panel));
  color: hsl(var(--primary));
}

.canvas-fit-button:focus-visible {
  outline: 2px solid hsl(var(--primary) / 0.52);
  outline-offset: 2px;
}

.canvas-fit-button:disabled {
  cursor: default;
  opacity: 0.45;
}

.canvas-fit-button svg {
  width: 16px;
  height: 16px;
  stroke-width: 1.7;
}

.model-canvas > :deep(.el-empty) {
  position: absolute;
  inset: 0;
  pointer-events: none;
}

.relation-layer {
  position: absolute;
  z-index: 1;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  overflow: visible;
  pointer-events: none;
}

.relation-layer path {
  fill: none;
}

.relation-path {
  stroke: hsl(var(--text-muted) / 0.72);
  stroke-width: 1.5;
  transition:
    stroke var(--motion-duration-fast) var(--motion-ease-standard),
    stroke-width var(--motion-duration-fast) var(--motion-ease-standard);
}

.relation-path.active {
  stroke: hsl(var(--primary));
  stroke-width: 2.25;
}

.relation-path.invalid {
  stroke: hsl(var(--destructive) / 0.72);
  stroke-dasharray: 5 4;
}

.relation-hit {
  pointer-events: stroke;
  stroke: transparent;
  stroke-width: 16;
  cursor: pointer;
}

.relation-hit:focus-visible {
  stroke: hsl(var(--primary) / 0.14);
  outline: none;
}

.relation-preview {
  stroke: hsl(var(--primary) / 0.72);
  stroke-width: 2;
  stroke-dasharray: 5 4;
  pointer-events: none;
}

.relation-label {
  position: absolute;
  z-index: 3;
  min-width: 46px;
  height: 22px;
  padding: 0 var(--space-2);
  transform: translate(-50%, -50%);
  border: 1px solid hsl(var(--line-subtle));
  border-radius: var(--radius-pill);
  background: hsl(var(--surface-panel));
  box-shadow: var(--shadow-subtle);
  color: hsl(var(--text-muted));
  cursor: pointer;
  font-size: 10px;
  font-weight: 650;
  letter-spacing: 0.02em;
  transition:
    border-color var(--motion-duration-fast) var(--motion-ease-standard),
    color var(--motion-duration-fast) var(--motion-ease-standard),
    transform var(--motion-duration-fast) var(--motion-ease-standard);
}

.relation-label:hover,
.relation-label.active {
  border-color: hsl(var(--primary) / 0.5);
  color: hsl(var(--primary));
  transform: translate(-50%, -50%) scale(1.04);
}

.relation-label.invalid {
  border-color: hsl(var(--destructive) / 0.42);
  color: hsl(var(--destructive));
}

.relation-label:focus-visible,
.relation-popover button:focus-visible,
.column-handle:focus-visible,
.node-expand:focus-visible,
.table-node header button:focus-visible {
  outline: 2px solid hsl(var(--primary) / 0.58);
  outline-offset: 2px;
}

.relation-popover {
  position: absolute;
  z-index: 5;
  width: max-content;
  gap: var(--space-2);
  padding: var(--space-2);
  transform: translate(-50%, calc(-100% - 18px));
  border: 1px solid hsl(var(--line-subtle));
  border-radius: var(--radius-toolbar);
  background: hsl(var(--surface-elevated));
  box-shadow: var(--shadow-float);
}

.relation-issue {
  display: inline-flex;
  max-width: 260px;
  align-items: center;
  gap: var(--space-1);
  color: hsl(var(--destructive));
  font-size: 11px;
  white-space: nowrap;
}

.relation-issue svg {
  width: 14px;
  height: 14px;
  flex: 0 0 auto;
}

.join-options {
  gap: 2px;
  padding: 2px;
  border-radius: var(--radius-control);
  background: hsl(var(--surface-contrast-soft));
}

.join-options button,
.relation-delete {
  height: 26px;
  border: 0;
  border-radius: calc(var(--radius-control) - 2px);
  background: transparent;
  color: hsl(var(--text-muted));
  cursor: pointer;
}

.join-options button {
  padding: 0 7px;
  font-size: 10px;
  font-weight: 600;
}

.join-options button:hover,
.join-options button.active {
  background: hsl(var(--surface-panel));
  color: hsl(var(--primary));
}

.relation-delete {
  display: inline-flex;
  width: 28px;
  align-items: center;
  justify-content: center;
}

.relation-delete:hover {
  background: hsl(var(--destructive) / 0.08);
  color: hsl(var(--destructive));
}

.relation-delete svg {
  width: 14px;
  height: 14px;
}

.table-node {
  position: absolute;
  z-index: 2;
  top: 0;
  left: 0;
  width: 292px;
  border: 1px solid hsl(var(--line-subtle));
  border-radius: var(--radius-toolbar);
  background: hsl(var(--surface-panel));
  box-shadow: var(--shadow-subtle);
  transition:
    border-color var(--motion-duration-fast) var(--motion-ease-standard),
    box-shadow var(--motion-duration-base) var(--motion-ease-standard);
}

.table-node:hover,
.table-node:focus-within {
  border-color: hsl(var(--primary) / 0.3);
  box-shadow: var(--shadow-toolbar);
}

.table-node.unavailable {
  border-color: hsl(var(--destructive) / 0.42);
}

.table-node.unavailable:hover,
.table-node.unavailable:focus-within {
  border-color: hsl(var(--destructive) / 0.62);
}

.table-node header {
  height: 58px;
  gap: 8px;
  padding: 0 12px;
  border-bottom: 1px solid hsl(var(--line-subtle));
  border-radius: var(--radius-toolbar) var(--radius-toolbar) 0 0;
  background: hsl(var(--surface-elevated));
  cursor: grab;
  touch-action: none;
  user-select: none;
}

.node-database-icon {
  display: inline-flex;
  width: 30px;
  height: 30px;
  flex: 0 0 auto;
  align-items: center;
  justify-content: center;
  border-radius: var(--radius-control);
  background: hsl(var(--nav-item-active));
}

.node-database-icon img {
  width: 20px;
  height: 20px;
}

.node-title {
  display: grid;
  min-width: 0;
  flex: 1;
  gap: 2px;
}

.node-name-button {
  display: flex;
  width: 100%;
  min-width: 0;
  max-width: 100%;
  height: 22px;
  align-items: center;
  justify-content: flex-start;
  gap: 6px;
  padding: 0;
  border: 0;
  background: transparent;
  color: hsl(var(--foreground));
  cursor: text;
  text-align: left;
}

.node-name-button strong {
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.node-name-button svg {
  width: 12px;
  height: 12px;
  flex: 0 0 auto;
  opacity: 0;
  stroke-width: 1.7;
  transition: opacity var(--motion-duration-fast) var(--motion-ease-standard);
}

.node-name-button:hover svg,
.node-name-button:focus-visible svg {
  opacity: 0.68;
}

.node-name-button:focus-visible {
  border-radius: 3px;
  outline: 2px solid hsl(var(--primary) / 0.5);
  outline-offset: 2px;
}

.node-name-editor {
  display: flex;
  height: 22px;
  align-items: center;
}

.node-name-editor input {
  width: 100%;
  height: 24px;
  padding: 0 6px;
  border: 1px solid hsl(var(--primary) / 0.52);
  border-radius: 5px;
  background: hsl(var(--surface-panel));
  color: hsl(var(--foreground));
  font: inherit;
  font-size: 13px;
  font-weight: 650;
  outline: none;
}

.node-name-editor input:focus {
  box-shadow: 0 0 0 2px hsl(var(--primary) / 0.1);
}

.node-title strong,
.node-title small {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.node-warning {
  width: 15px;
  height: 15px;
  flex: 0 0 auto;
  color: hsl(var(--destructive));
}

.node-title strong {
  font-size: 13px;
  font-weight: 650;
}

.node-title small {
  color: hsl(var(--text-muted));
  font-size: 10px;
  font-weight: 400;
}

.table-node header > button {
  display: inline-flex;
  width: 28px;
  height: 28px;
  align-items: center;
  justify-content: center;
  border: 0;
  border-radius: var(--radius-control);
  background: transparent;
  color: hsl(var(--text-muted));
  cursor: pointer;
}

.table-node header > button:hover {
  background: hsl(var(--surface-contrast-soft));
  color: hsl(var(--foreground));
}

.table-node header > button svg {
  width: 15px;
  height: 15px;
}

.node-columns > div {
  position: relative;
  height: 36px;
  gap: 8px;
  padding: 0 12px;
  font-size: 12px;
  transition: background-color var(--motion-duration-fast)
    var(--motion-ease-standard);
}

.node-columns > div:hover {
  background: hsl(var(--table-row-hover));
}

.node-columns > div > svg {
  width: 14px;
  height: 14px;
  flex: 0 0 auto;
  color: hsl(var(--text-muted));
}

.node-columns > div > .primary-key-icon {
  color: hsl(var(--primary) / 0.76);
}

.node-columns > div span {
  min-width: 0;
  flex: 1;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.node-columns small {
  max-width: 86px;
  overflow: hidden;
  color: hsl(var(--text-muted));
  font-size: 10px;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.column-handle {
  position: absolute;
  z-index: 3;
  top: 50%;
  width: 12px;
  height: 12px;
  padding: 0;
  transform: translateY(-50%);
  border: 2px solid hsl(var(--surface-panel));
  border-radius: 50%;
  background: hsl(var(--text-muted) / 0.72);
  box-shadow: 0 0 0 1px hsl(var(--line-subtle));
  cursor: crosshair;
  opacity: 0.46;
  transition:
    background-color var(--motion-duration-fast) var(--motion-ease-standard),
    box-shadow var(--motion-duration-fast) var(--motion-ease-standard),
    opacity var(--motion-duration-fast) var(--motion-ease-standard),
    transform var(--motion-duration-fast) var(--motion-ease-standard);
}

.column-handle.left {
  left: -6px;
}

.column-handle.right {
  right: -6px;
}

.node-column:hover .column-handle,
.column-handle.connecting,
.column-handle:focus-visible {
  opacity: 1;
}

.column-handle:hover,
.column-handle.active {
  background: hsl(var(--primary));
  box-shadow: 0 0 0 4px hsl(var(--primary) / 0.14);
  opacity: 1;
  transform: translateY(-50%) scale(1.12);
}

.column-handle:disabled {
  cursor: not-allowed;
  opacity: 0.18;
  pointer-events: none;
}

.node-expand {
  width: 100%;
  height: 34px;
  justify-content: center;
  gap: var(--space-1);
  border-top: 1px solid hsl(var(--line-subtle));
  border-right: 0;
  border-bottom: 0;
  border-left: 0;
  border-radius: 0 0 var(--radius-toolbar) var(--radius-toolbar);
  background: hsl(var(--surface-subtle));
  color: hsl(var(--text-muted));
  cursor: pointer;
  font-size: 11px;
  transition:
    background-color var(--motion-duration-fast) var(--motion-ease-standard),
    color var(--motion-duration-fast) var(--motion-ease-standard);
}

.node-expand:hover {
  background: hsl(var(--nav-item-hover));
  color: hsl(var(--primary));
}

.node-expand svg {
  width: 14px;
  height: 14px;
}

@media (max-width: 1180px) {
  .space-row-main {
    grid-template-columns: 32px minmax(140px, 1fr) 180px 80px;
  }

  .space-row-main time {
    display: none;
  }

  .editor-body {
    grid-template-columns: 248px minmax(520px, 1fr);
  }
}

@media (max-width: 860px) {
  .space-list-main {
    padding: 0 var(--dataspace-toolbar-padding-inline) var(--space-3);
  }

  .space-row-main {
    grid-template-columns: 32px minmax(140px, 1fr) 80px;
  }

  .space-counts {
    display: none;
  }
}
</style>
