/**
 * SourcesTab – Full data-source management inside the Unified Data Bar.
 *
 * Tree structure (Browse Sources):
 *   UserConnection (Level 1, loaded on mount)
 *     └─ Service (Level 2, loaded when connection expanded)
 *         └─ Folder / Site / File (Level 3+, loaded when service/folder expanded)
 *
 * Feature Data tree (catalog-driven, supports recursive nesting):
 *   MandateGroup
 *     └─ FeatureConnection (feature instance)
 *         ├─ Group (categorical folder, isGroup=true)
 *         │   └─ ParentGroup or Table
 *         ├─ ParentGroup (table with isParent=true) → records
 *         │   └─ Record → child tables (which can themselves be ParentGroups → recursion)
 *         └─ Table (standalone)
 *
 * Path-aware state-keys (segments joined by '|', prefixed by featureInstanceId):
 *   g:<objectKey>          - categorical group folder
 *   p:<tableName>          - parent group (record list of that table)
 *   r:<tableName>:<id>     - specific record (its child tables rendered when expanded)
 *
 * Active Sources sections show scope-cycling and neutralize-toggle buttons.
 */

import React, { useEffect, useState, useCallback, useRef, useMemo } from 'react';
import type { UdbContext } from './UnifiedDataBar';
import api from '../../api';
import { getPageIcon } from '../../config/pageRegistry';
import styles from './SourcesTab.module.css';

import { useLanguage } from '../../providers/language/LanguageContext';

/* ─── Types (inline, no external imports) ────────────────────────────── */

interface UdbDataSource {
  id: string;
  connectionId: string;
  sourceType: string;
  path: string;
  label: string;
  displayPath?: string;
  scope: string;
  neutralize: boolean;
}

interface UdbFeatureDataSource {
  id: string;
  featureInstanceId: string;
  featureCode: string;
  tableName: string;
  objectKey: string;
  label: string;
  scope: string;
  neutralize: boolean;
  neutralizeFields?: string[];
  recordFilter?: Record<string, string>;
}

interface TreeNode {
  key: string;
  label: string;
  icon: string;
  type: 'connection' | 'service' | 'folder' | 'file';
  expanded: boolean;
  loading: boolean;
  children: TreeNode[] | null;
  connectionId: string;
  service?: string;
  path?: string;
  displayPath?: string;
  authority?: string;
}

interface FeatureConnectionNode {
  featureInstanceId: string;
  featureCode: string;
  mandateId?: string;
  label: string;
  icon: string;
  tableCount: number;
  expanded: boolean;
  loading: boolean;
  tables: FeatureTableNode[] | null;
}

interface MandateGroupNode {
  mandateId: string;
  mandateLabel: string;
  expanded: boolean;
  featureConnections: FeatureConnectionNode[];
}

interface FeatureTableNode {
  objectKey: string;
  tableName: string;
  label: string;
  fields: string[];
  isParent?: boolean;
  parentTable?: string | null;
  parentKey?: string | null;
  displayFields?: string[];
  isGroup?: boolean;
  group?: string | null;
}

interface ParentRecordNode {
  id: string;
  displayLabel: string;
  fields: Record<string, any>;
  tableName: string;
}

/* ─── Props ──────────────────────────────────────────────────────────── */

interface SourcesTabProps {
  context: UdbContext;
  onSourcesChanged?: () => void;
  onSendToChat_FeatureSource?: (params: { featureInstanceId: string; featureCode: string; tableName?: string; objectKey: string; label: string; fieldName?: string }) => void;
  onAttachDataSource?: (dsId: string) => void;
}

/* ─── Icons ──────────────────────────────────────────────────────────── */

const _AUTHORITY_ICONS: Record<string, string> = {
  msft: '\uD83D\uDFE6',
  google: '\uD83D\uDFE9',
  clickup: '\uD83D\uDCCB',
  infomaniak: '\uD83D\uDFE5',
  'local:ftp': '\uD83D\uDD17',
  'local:jira': '\uD83D\uDD27',
};

const _SERVICE_ICONS: Record<string, string> = {
  sharepoint: '\uD83D\uDCC1',
  onedrive: '\u2601\uFE0F',
  outlook: '\uD83D\uDCE7',
  teams: '\uD83D\uDCAC',
  drive: '\uD83D\uDCC2',
  gmail: '\uD83D\uDCE8',
  files: '\uD83D\uDCC2',
  clickup: '\uD83D\uDCCB',
  kdrive: '\uD83D\uDCC2',
  mail: '\uD83D\uDCE7',
  calendar: '\uD83D\uDCC5',
  contact: '\uD83D\uDC64',
};

/* ─── Source colors & icons ──────────────────────────────────────────── */

const _SOURCE_COLORS: Record<string, string> = {
  sharepointFolder: '#0078d4',
  sharepoint: '#0078d4',
  onedriveFolder: '#0078d4',
  onedrive: '#0078d4',
  outlookFolder: '#0078d4',
  outlook: '#0078d4',
  googleDriveFolder: '#34a853',
  drive: '#34a853',
  gmailFolder: '#ea4335',
  gmail: '#ea4335',
  ftpFolder: '#795548',
  files: '#795548',
  'local:ftp': '#795548',
  'local:jira': '#0052CC',
  clickup: '#7b68ee',
  kdriveFolder: '#0098FF',
  kdrive: '#0098FF',
  mailFolder: '#0098FF',
  mail: '#0098FF',
  calendarFolder: '#0098FF',
  calendar: '#0098FF',
  contactFolder: '#0098FF',
  contact: '#0098FF',
};

function _getSourceColor(sourceType: string): string {
  return _SOURCE_COLORS[sourceType] || '#F25843';
}

/* ─── Scope / Neutralize constants ───────────────────────────────────── */

const _SCOPE_ORDER: string[] = ['personal', 'featureInstance', 'mandate'];

const _SCOPE_ICONS: Record<string, string> = {
  personal: '\uD83D\uDC64',
  featureInstance: '\uD83D\uDC65',
  mandate: '\uD83C\uDFE2',
  global: '\uD83C\uDF10',
};

function _nextScope(current: string): string {
  const idx = _SCOPE_ORDER.indexOf(current);
  if (idx === -1) return _SCOPE_ORDER[0];
  return _SCOPE_ORDER[(idx + 1) % _SCOPE_ORDER.length];
}

const _SERVICE_TO_SOURCE_TYPE: Record<string, string> = {
  sharepoint: 'sharepointFolder',
  onedrive: 'onedriveFolder',
  outlook: 'outlookFolder',
  drive: 'googleDriveFolder',
  gmail: 'gmailFolder',
  files: 'ftpFolder',
  clickup: 'clickup',
  kdrive: 'kdriveFolder',
  mail: 'mailFolder',
  calendar: 'calendarFolder',
  contact: 'contactFolder',
};

/* ─── Tree helpers ───────────────────────────────────────────────────── */

function _mapTree(nodes: TreeNode[], key: string, updater: (n: TreeNode) => TreeNode): TreeNode[] {
  return nodes.map(n => {
    if (n.key === key) return updater(n);
    if (n.children) return { ...n, children: _mapTree(n.children, key, updater) };
    return n;
  });
}

function _mapFeatureTreeUpdate(
  prev: MandateGroupNode[],
  featureInstanceId: string,
  updater: (n: FeatureConnectionNode) => FeatureConnectionNode,
): MandateGroupNode[] {
  return prev.map(g => ({
    ...g,
    featureConnections: g.featureConnections.map(n =>
      n.featureInstanceId === featureInstanceId ? updater(n) : n
    ),
  }));
}

function _findTableFields(
  groups: MandateGroupNode[],
  featureInstanceId: string,
  tableName: string,
): string[] {
  for (const g of groups) {
    const fc = g.featureConnections.find(f => f.featureInstanceId === featureInstanceId);
    if (fc?.tables) {
      const tbl = fc.tables.find(t => t.tableName === tableName);
      if (tbl) return tbl.fields;
    }
  }
  return [];
}

/* ─── Feature tree builder (catalog → renderable items) ──────────────── */

type FeatureItem =
  | { kind: 'group'; objectKey: string; label: string; items: FeatureItem[] }
  | { kind: 'parentGroup'; table: FeatureTableNode }
  | { kind: 'table'; table: FeatureTableNode };

/**
 * Build the top-level feature tree from the flat catalog table list.
 *
 * - Items with `isGroup: true` become categorical group folders.
 * - Items with `group: <objectKey>` are placed inside the corresponding group.
 * - Items with `parentTable` set are NOT rendered at top level — they are
 *   rendered dynamically when a parent record is expanded.
 * - Items with `isParent: true` (and no parentTable) become top-level parent groups.
 * - Everything else renders as a standalone table.
 *
 * Catalog declaration order is preserved; group children appear nested under
 * the group folder in the order they were declared.
 */
function _buildTopFeatureTree(tables: FeatureTableNode[]): FeatureItem[] {
  const groupChildren: Record<string, FeatureItem[]> = {};
  for (const t of tables) {
    if (t.isGroup) groupChildren[t.objectKey] = [];
  }

  const result: FeatureItem[] = [];
  for (const t of tables) {
    if (t.isGroup) {
      result.push({ kind: 'group', objectKey: t.objectKey, label: t.label, items: groupChildren[t.objectKey] });
    } else if (t.parentTable) {
      // Skip — child tables are rendered when their parent record is expanded.
      continue;
    } else if (t.group && groupChildren[t.group]) {
      const item: FeatureItem = t.isParent
        ? { kind: 'parentGroup', table: t }
        : { kind: 'table', table: t };
      groupChildren[t.group].push(item);
    } else if (t.isParent) {
      result.push({ kind: 'parentGroup', table: t });
    } else {
      result.push({ kind: 'table', table: t });
    }
  }
  return result;
}

/**
 * Children of a parent record: child tables (where parentTable === recordTableName)
 * rendered as parentGroup or standalone table (recursion enables N-level nesting).
 */
function _childrenForRecord(allTables: FeatureTableNode[], parentTableName: string): FeatureItem[] {
  return allTables
    .filter(t => t.parentTable === parentTableName)
    .map<FeatureItem>(t => t.isParent
      ? { kind: 'parentGroup', table: t }
      : { kind: 'table', table: t });
}

function _pathKey(featureInstanceId: string, segments: string[]): string {
  return [featureInstanceId, ...segments].join('|');
}

/** Walks back through a path to find the closest preceding `r:<table>:<id>` segment. */
function _closestRecordSegment(segments: string[]): { tableName: string; recordId: string } | null {
  for (let i = segments.length - 1; i >= 0; i--) {
    const seg = segments[i];
    if (seg.startsWith('r:')) {
      const rest = seg.slice(2);
      const sepIdx = rest.indexOf(':');
      if (sepIdx > 0) {
        return { tableName: rest.slice(0, sepIdx), recordId: rest.slice(sepIdx + 1) };
      }
    }
  }
  return null;
}

/* ─── Data fetching (module-level) ───────────────────────────────────── */

async function _loadServices(instanceId: string, connectionId: string): Promise<TreeNode[]> {
  const res = await api.get(`/api/workspace/${instanceId}/connections/${connectionId}/services`);
  const services = res.data.services || [];
  return services.map((s: any) => ({
    key: `svc-${connectionId}-${s.service}`,
    label: s.label || s.service,
    icon: _SERVICE_ICONS[s.service] || '\uD83D\uDCC2',
    type: 'service' as const,
    expanded: false,
    loading: false,
    children: null,
    connectionId,
    service: s.service,
    path: '/',
    displayPath: s.label || s.service,
  }));
}

async function _browseService(
  instanceId: string,
  connectionId: string,
  service: string,
  path: string,
  parentDisplayPath: string | undefined,
): Promise<TreeNode[]> {
  const res = await api.get(`/api/workspace/${instanceId}/connections/${connectionId}/browse`, {
    params: { service, path },
  });
  const items = res.data.items || [];
  return items.map((entry: any, idx: number) => {
    const seg = entry.name || '';
    const displayPath = parentDisplayPath
      ? `${parentDisplayPath} / ${seg}`
      : seg;
    return {
      key: `item-${connectionId}-${service}-${entry.path || idx}`,
      label: entry.name,
      icon: entry.isFolder ? '\uD83D\uDCC1' : _fileIcon(entry.name),
      type: entry.isFolder ? 'folder' as const : 'file' as const,
      expanded: false,
      loading: false,
      children: entry.isFolder ? null : [],
      connectionId,
      service,
      path: entry.path,
      displayPath,
    };
  });
}

function _fileIcon(name: string): string {
  const ext = name.split('.').pop()?.toLowerCase() || '';
  const map: Record<string, string> = {
    pdf: '\uD83D\uDCC4', doc: '\uD83D\uDCDD', docx: '\uD83D\uDCDD',
    xls: '\uD83D\uDCCA', xlsx: '\uD83D\uDCCA', csv: '\uD83D\uDCCA',
    ppt: '\uD83D\uDCC8', pptx: '\uD83D\uDCC8',
    txt: '\uD83D\uDCC4', json: '\uD83D\uDCCB',
    png: '\uD83D\uDDBC\uFE0F', jpg: '\uD83D\uDDBC\uFE0F', jpeg: '\uD83D\uDDBC\uFE0F',
    zip: '\uD83D\uDCE6', rar: '\uD83D\uDCE6',
    mp3: '\uD83C\uDFB5', wav: '\uD83C\uDFB5',
    mp4: '\uD83C\uDFAC', mov: '\uD83C\uDFAC',
  };
  return map[ext] || '\uD83D\uDCC4';
}

/* ─── Spinner (inline) ───────────────────────────────────────────────── */

function _Spinner(): React.ReactElement {
  return (
    <span style={{
      display: 'inline-block', width: 10, height: 10,
      border: '1.5px solid var(--border-color, #ccc)', borderTopColor: 'var(--primary-color, #F25843)',
      borderRadius: '50%',
      animation: 'spin 0.6s linear infinite',
    }} />
  );
}

/* ─── Component ──────────────────────────────────────────────────────── */

const SourcesTab: React.FC<SourcesTabProps> = ({ context, onSourcesChanged, onSendToChat_FeatureSource, onAttachDataSource }) => {
  const { t } = useLanguage();
  const _scopeLabel = (scope: string) => ({
    personal: t('Persönlich'),
    featureInstance: t('Feature-Instanz'),
    mandate: t('Mandant'),
    global: t('Global'),
  } as Record<string, string>)[scope] || scope;
  const _scopeCycleTitle = (scope: string) =>
    `${t('Bereich')}: ${_scopeLabel(scope)} → ${_scopeLabel(_nextScope(scope))}`;
  const instanceId = context.instanceId;

  /* ── Active sources (fetched internally) ── */
  const [dataSources, setDataSources] = useState<UdbDataSource[]>([]);
  const [featureDataSources, setFeatureDataSources] = useState<UdbFeatureDataSource[]>([]);

  /* ── Browse tree state ── */
  const [tree, setTree] = useState<TreeNode[]>([]);
  const [loadingRoot, setLoadingRoot] = useState(false);
  const [addingPath, setAddingPath] = useState<string | null>(null);

  /* ── Feature tree state ── */
  const [featureTree, setFeatureTree] = useState<MandateGroupNode[]>([]);
  const [loadingFeatures, setLoadingFeatures] = useState(false);
  const [addingFeatureKey, setAddingFeatureKey] = useState<string | null>(null);

  /* ── Path-aware feature node state (groups, parent groups, records) ── */
  const [featureExpandedPaths, setFeatureExpandedPaths] = useState<Set<string>>(new Set());
  const [featureRecordsByPath, setFeatureRecordsByPath] = useState<Record<string, ParentRecordNode[]>>({});
  const [featureLoadingPath, setFeatureLoadingPath] = useState<string | null>(null);
  const [addingRecordPath, setAddingRecordPath] = useState<string | null>(null);

  /* ── Multi-selection state for Browse-Tree ── */
  const [selectedKeys, setSelectedKeys] = useState<Set<string>>(new Set());
  const lastClickedKeyRef = useRef<string | null>(null);

  const _flattenVisibleKeys = useCallback((nodes: TreeNode[]): string[] => {
    const result: string[] = [];
    for (const n of nodes) {
      result.push(n.key);
      if (n.expanded && n.children) {
        result.push(..._flattenVisibleKeys(n.children));
      }
    }
    return result;
  }, []);

  const _handleNodeSelect = useCallback((node: TreeNode, e: React.MouseEvent) => {
    if (e.ctrlKey || e.metaKey) {
      setSelectedKeys(prev => {
        const next = new Set(prev);
        if (next.has(node.key)) next.delete(node.key); else next.add(node.key);
        return next;
      });
      lastClickedKeyRef.current = node.key;
    } else if (e.shiftKey && lastClickedKeyRef.current) {
      const visible = _flattenVisibleKeys(tree);
      const a = visible.indexOf(lastClickedKeyRef.current);
      const b = visible.indexOf(node.key);
      if (a !== -1 && b !== -1) {
        const [start, end] = a < b ? [a, b] : [b, a];
        setSelectedKeys(new Set(visible.slice(start, end + 1)));
      }
    } else {
      setSelectedKeys(new Set([node.key]));
      lastClickedKeyRef.current = node.key;
    }
  }, [tree, _flattenVisibleKeys]);

  const mountedRef = useRef(true);
  useEffect(() => { mountedRef.current = true; return () => { mountedRef.current = false; }; }, []);

  /* ── Fetch active personal data sources ── */
  const _fetchDataSources = useCallback(() => {
    if (!instanceId) return;
    api.get(`/api/workspace/${instanceId}/datasources`)
      .then(res => {
        if (!mountedRef.current) return;
        const list: UdbDataSource[] = (res.data.dataSources || res.data || []).map((d: any) => ({
          id: d.id,
          connectionId: d.connectionId,
          sourceType: d.sourceType,
          path: d.path,
          label: d.label,
          displayPath: d.displayPath,
          scope: d.scope || 'personal',
          neutralize: d.neutralize ?? false,
        }));
        setDataSources(list);
      })
      .catch(() => { if (mountedRef.current) setDataSources([]); });
  }, [instanceId]);

  /* ── Fetch active feature data sources ── */
  const _fetchFeatureDataSources = useCallback(() => {
    if (!instanceId) return;
    api.get(`/api/workspace/${instanceId}/feature-datasources`)
      .then(res => {
        if (!mountedRef.current) return;
        const list: UdbFeatureDataSource[] = (res.data.featureDataSources || res.data || []).map((d: any) => ({
          id: d.id,
          featureInstanceId: d.featureInstanceId,
          featureCode: d.featureCode,
          tableName: d.tableName,
          objectKey: d.objectKey,
          label: d.label,
          scope: d.scope || 'personal',
          neutralize: d.neutralize ?? false,
          neutralizeFields: d.neutralizeFields || undefined,
          recordFilter: d.recordFilter || undefined,
        }));
        setFeatureDataSources(list);
      })
      .catch(() => { if (mountedRef.current) setFeatureDataSources([]); });
  }, [instanceId]);

  useEffect(() => { _fetchDataSources(); }, [_fetchDataSources]);
  useEffect(() => { _fetchFeatureDataSources(); }, [_fetchFeatureDataSources]);

  /* ── Load Level 1: UserConnections ── */
  const _loadConnections = useCallback(() => {
    if (!instanceId) return;
    setLoadingRoot(true);
    api.get(`/api/workspace/${instanceId}/connections`)
      .then(res => {
        if (!mountedRef.current) return;
        const conns = res.data.connections || [];
        const nodes: TreeNode[] = conns
          .filter((c: any) => c.status === 'active')
          .map((c: any) => ({
            key: `conn-${c.id}`,
            label: c.externalEmail || c.externalUsername || c.authority,
            icon: _AUTHORITY_ICONS[c.authority] || '\uD83D\uDD17',
            type: 'connection' as const,
            expanded: false,
            loading: false,
            children: null,
            connectionId: c.id,
            authority: c.authority,
          }));
        setTree(nodes);
      })
      .catch(() => { if (mountedRef.current) setTree([]); })
      .finally(() => { if (mountedRef.current) setLoadingRoot(false); });
  }, [instanceId]);

  useEffect(() => { _loadConnections(); }, [_loadConnections]);

  /* ── Generic tree update helper ── */
  const _updateNode = useCallback((key: string, updater: (node: TreeNode) => TreeNode) => {
    setTree(prev => _mapTree(prev, key, updater));
  }, []);

  /* ── Toggle expand/collapse ── */
  const _toggleNode = useCallback(async (node: TreeNode) => {
    if (node.expanded) {
      _updateNode(node.key, n => ({ ...n, expanded: false }));
      return;
    }

    if (node.children !== null) {
      _updateNode(node.key, n => ({ ...n, expanded: true }));
      return;
    }

    _updateNode(node.key, n => ({ ...n, loading: true, expanded: true }));

    try {
      let children: TreeNode[] = [];

      if (node.type === 'connection') {
        children = await _loadServices(instanceId, node.connectionId);
      } else if (node.type === 'service' || node.type === 'folder') {
        children = await _browseService(
          instanceId,
          node.connectionId,
          node.service!,
          node.path || '/',
          node.displayPath || node.label,
        );
      }

      if (mountedRef.current) {
        _updateNode(node.key, n => ({ ...n, loading: false, children }));
      }
    } catch {
      if (mountedRef.current) {
        _updateNode(node.key, n => ({ ...n, loading: false, children: [] }));
      }
    }
  }, [instanceId, _updateNode]);

  /* ── Add as DataSource ── */
  const _addAsDataSource = useCallback(async (node: TreeNode): Promise<string | null> => {
    if (!node.connectionId) return null;
    const sourceType = node.service
      ? (_SERVICE_TO_SOURCE_TYPE[node.service] || node.service)
      : (node.authority || node.type);
    setAddingPath(node.key);
    try {
      const res = await api.post(`/api/workspace/${instanceId}/datasources`, {
        connectionId: node.connectionId,
        sourceType,
        path: node.path || '/',
        label: node.label,
        displayPath: node.displayPath || node.label,
      });
      _fetchDataSources();
      onSourcesChanged?.();
      return res.data?.id || res.data?.dataSource?.id || null;
    } catch (err) {
      console.error('Failed to add data source:', err);
      return null;
    } finally {
      if (mountedRef.current) setAddingPath(null);
    }
  }, [instanceId, _fetchDataSources, onSourcesChanged]);

  /* ── Check if a path is already added ── */
  const _isAdded = useCallback((connectionId: string, service: string | undefined, path: string | undefined): boolean => {
    const expectedSourceType = service ? (_SERVICE_TO_SOURCE_TYPE[service] || service) : undefined;
    return dataSources.some(ds =>
      ds.connectionId === connectionId &&
      ds.path === (path || '/') &&
      (!expectedSourceType || ds.sourceType === expectedSourceType),
    );
  }, [dataSources]);

  /* ── Send node to chat: ensure DataSource exists, then attach ── */
  const _sendNodeToChat = useCallback(async (params: { connectionId: string; sourceType: string; path: string; label: string; displayPath?: string }) => {
    if (!onAttachDataSource) return;
    const expectedSourceType = params.sourceType;
    let ds = dataSources.find(d =>
      d.connectionId === params.connectionId &&
      d.path === (params.path || '/') &&
      d.sourceType === expectedSourceType,
    );
    if (ds) {
      onAttachDataSource(ds.id);
      return;
    }
    try {
      const res = await api.post(`/api/workspace/${instanceId}/datasources`, {
        connectionId: params.connectionId,
        sourceType: params.sourceType,
        path: params.path || '/',
        label: params.label,
        displayPath: params.displayPath || params.label,
      });
      const newId = res.data?.id || res.data?.dataSource?.id;
      if (newId) {
        onAttachDataSource(newId);
        _fetchDataSources();
        onSourcesChanged?.();
      }
    } catch (err) {
      console.error('Failed to send data source to chat:', err);
    }
  }, [instanceId, dataSources, onAttachDataSource, _fetchDataSources, onSourcesChanged]);

  /* ── Scope change (personal data source, optimistic) ── */
  const _cyclePersonalScope = useCallback(async (ds: UdbDataSource) => {
    const newScope = _nextScope(ds.scope);
    setDataSources(prev => prev.map(d => d.id === ds.id ? { ...d, scope: newScope } : d));
    try {
      await api.patch(`/api/datasources/${ds.id}/scope`, { scope: newScope });
    } catch {
      setDataSources(prev => prev.map(d => d.id === ds.id ? { ...d, scope: ds.scope } : d));
    }
  }, []);

  /* ── Neutralize toggle (personal data source, optimistic) ── */
  const _togglePersonalNeutralize = useCallback(async (ds: UdbDataSource) => {
    const newValue = !ds.neutralize;
    setDataSources(prev => prev.map(d => d.id === ds.id ? { ...d, neutralize: newValue } : d));
    try {
      await api.patch(`/api/datasources/${ds.id}/neutralize`, { neutralize: newValue });
    } catch {
      setDataSources(prev => prev.map(d => d.id === ds.id ? { ...d, neutralize: ds.neutralize } : d));
    }
  }, []);

  /* ── Scope change (feature data source, optimistic) ── */
  const _cycleFeatureScope = useCallback(async (fds: UdbFeatureDataSource) => {
    const newScope = _nextScope(fds.scope);
    setFeatureDataSources(prev => prev.map(d => d.id === fds.id ? { ...d, scope: newScope } : d));
    try {
      await api.patch(`/api/datasources/${fds.id}/scope`, { scope: newScope });
    } catch {
      setFeatureDataSources(prev => prev.map(d => d.id === fds.id ? { ...d, scope: fds.scope } : d));
    }
  }, []);

  /* ── Neutralize toggle (feature data source, optimistic) ── */
  const _toggleFeatureNeutralize = useCallback(async (fds: UdbFeatureDataSource) => {
    const newValue = !fds.neutralize;
    setFeatureDataSources(prev => prev.map(d => d.id === fds.id ? { ...d, neutralize: newValue } : d));
    try {
      await api.patch(`/api/datasources/${fds.id}/neutralize`, { neutralize: newValue });
    } catch {
      setFeatureDataSources(prev => prev.map(d => d.id === fds.id ? { ...d, neutralize: fds.neutralize } : d));
    }
  }, []);

  /* ── Neutralize fields toggle (field-level, optimistic) ── */
  const _toggleNeutralizeField = useCallback(async (fds: UdbFeatureDataSource, fieldName: string) => {
    const current = fds.neutralizeFields || [];
    const updated = current.includes(fieldName)
      ? current.filter(f => f !== fieldName)
      : [...current, fieldName];
    const newFields = updated.length > 0 ? updated : undefined;
    setFeatureDataSources(prev => prev.map(d => d.id === fds.id ? { ...d, neutralizeFields: newFields } : d));
    try {
      await api.patch(`/api/datasources/${fds.id}/neutralize-fields`, { neutralizeFields: newFields || [] });
    } catch {
      setFeatureDataSources(prev => prev.map(d => d.id === fds.id ? { ...d, neutralizeFields: fds.neutralizeFields } : d));
    }
  }, []);

  /* ── Feature Connections: Load Level 1 ── */
  const _loadFeatureConnections = useCallback(() => {
    if (!instanceId) return;
    setLoadingFeatures(true);
    api.get(`/api/workspace/${instanceId}/feature-connections`)
      .then(res => {
        if (!mountedRef.current) return;
        const groups = res.data.featureConnectionsByMandate || [];
        setFeatureTree(groups.map((g: any) => ({
          mandateId: g.mandateId,
          mandateLabel: g.mandateLabel || g.mandateId,
          expanded: true,
          featureConnections: (g.featureConnections || []).map((c: any) => ({
            featureInstanceId: c.featureInstanceId,
            featureCode: c.featureCode,
            mandateId: c.mandateId,
            label: c.label,
            icon: c.icon || '\uD83D\uDDC3\uFE0F',
            tableCount: c.tableCount || 0,
            expanded: false,
            loading: false,
            tables: null,
          })),
        })));
      })
      .catch(() => { if (mountedRef.current) setFeatureTree([]); })
      .finally(() => { if (mountedRef.current) setLoadingFeatures(false); });
  }, [instanceId]);

  useEffect(() => { _loadFeatureConnections(); }, [_loadFeatureConnections]);

  /* ── Feature Connections: Toggle mandate group ── */
  const _toggleMandateGroup = useCallback((mandateId: string) => {
    setFeatureTree(prev => prev.map(g =>
      g.mandateId === mandateId ? { ...g, expanded: !g.expanded } : g
    ));
  }, []);

  /* ── Feature Connections: Toggle expand ── */
  const _toggleFeatureNode = useCallback(async (node: FeatureConnectionNode) => {
    if (node.expanded) {
      setFeatureTree(prev => _mapFeatureTreeUpdate(prev, node.featureInstanceId, n => ({ ...n, expanded: false })));
      return;
    }

    if (node.tables !== null) {
      setFeatureTree(prev => _mapFeatureTreeUpdate(prev, node.featureInstanceId, n => ({ ...n, expanded: true })));
      return;
    }

    setFeatureTree(prev => _mapFeatureTreeUpdate(prev, node.featureInstanceId, n => ({
      ...n, loading: true, expanded: true,
    })));

    try {
      const res = await api.get(`/api/workspace/${instanceId}/feature-connections/${node.featureInstanceId}/tables`);
      const tables: FeatureTableNode[] = (res.data.tables || []).map((t: any) => ({
        objectKey: t.objectKey ?? '',
        tableName: t.tableName ?? '',
        label: t.label ?? '',
        fields: t.fields ?? [],
        isParent: Boolean(t.isParent),
        parentTable: t.parentTable ?? null,
        parentKey: t.parentKey ?? null,
        displayFields: t.displayFields ?? [],
        isGroup: Boolean(t.isGroup),
        group: t.group ?? null,
      }));

      // Default-expand all categorical groups so users immediately see their content.
      const defaultExpansions: string[] = tables
        .filter(t => t.isGroup)
        .map(t => _pathKey(node.featureInstanceId, [`g:${t.objectKey}`]));
      if (defaultExpansions.length > 0) {
        setFeatureExpandedPaths(prev => {
          const next = new Set(prev);
          for (const k of defaultExpansions) next.add(k);
          return next;
        });
      }

      if (mountedRef.current) {
        setFeatureTree(prev => _mapFeatureTreeUpdate(prev, node.featureInstanceId, n => ({
          ...n, loading: false, tables,
        })));
      }
    } catch {
      if (mountedRef.current) {
        setFeatureTree(prev => _mapFeatureTreeUpdate(prev, node.featureInstanceId, n => ({
          ...n, loading: false, tables: [],
        })));
      }
    }
  }, [instanceId]);

  /* ── Feature: Add table as FeatureDataSource ── */
  const _addFeatureTable = useCallback(async (
    node: FeatureConnectionNode,
    table: FeatureTableNode,
    extra?: { recordFilter?: Record<string, string>; labelOverride?: string },
  ): Promise<string | null> => {
    const key = `${node.featureInstanceId}-${table.tableName}`;
    setAddingFeatureKey(key);
    try {
      const res = await api.post(`/api/workspace/${instanceId}/feature-datasources`, {
        featureInstanceId: node.featureInstanceId,
        featureCode: node.featureCode,
        tableName: table.tableName,
        objectKey: table.objectKey,
        label: extra?.labelOverride || table.label || table.tableName,
        ...(extra?.recordFilter ? { recordFilter: extra.recordFilter } : {}),
      });
      _fetchFeatureDataSources();
      onSourcesChanged?.();
      return res.data?.id || null;
    } catch (err) {
      console.error('Failed to add feature data source:', err);
      return null;
    } finally {
      if (mountedRef.current) setAddingFeatureKey(null);
    }
  }, [instanceId, _fetchFeatureDataSources, onSourcesChanged]);

  /* ── Feature: check if table already added (no record filter) ── */
  const _isFeatureTableAdded = useCallback((featureInstanceId: string, tableName: string): boolean => {
    return featureDataSources.some(fds =>
      fds.featureInstanceId === featureInstanceId
      && fds.tableName === tableName
      && !fds.recordFilter,
    );
  }, [featureDataSources]);

  /* ── Feature: toggle expand for a path-keyed node (group / parent group / record) ── */
  const _toggleFeaturePath = useCallback((pathKey: string) => {
    setFeatureExpandedPaths(prev => {
      const next = new Set(prev);
      if (next.has(pathKey)) next.delete(pathKey); else next.add(pathKey);
      return next;
    });
  }, []);

  /**
   * Load records for a parent group at a given path.
   * If the path contains a preceding `r:<table>:<id>` segment, the records are
   * filtered to children of that ancestor record (nested record hierarchy).
   */
  const _loadRecordsAtPath = useCallback(async (
    node: FeatureConnectionNode,
    table: FeatureTableNode,
    parentPathSegments: string[],
  ) => {
    const segments = [...parentPathSegments, `p:${table.tableName}`];
    const pathKey = _pathKey(node.featureInstanceId, segments);

    if (featureRecordsByPath[pathKey]) return;

    setFeatureLoadingPath(pathKey);
    try {
      const params: Record<string, string> = {};
      const ancestor = _closestRecordSegment(parentPathSegments);
      if (ancestor && table.parentKey) {
        params.parentKey = table.parentKey;
        params.parentValue = ancestor.recordId;
      }

      const res = await api.get(
        `/api/workspace/${instanceId}/feature-connections/${node.featureInstanceId}/parent-objects/${table.tableName}`,
        Object.keys(params).length > 0 ? { params } : undefined,
      );
      const records: ParentRecordNode[] = (res.data.parentObjects || []).map((r: any) => ({
        id: r.id,
        displayLabel: r.displayLabel || r.id,
        fields: r.fields || {},
        tableName: table.tableName,
      }));
      if (mountedRef.current) {
        setFeatureRecordsByPath(prev => ({ ...prev, [pathKey]: records }));
      }
    } catch {
      if (mountedRef.current) {
        setFeatureRecordsByPath(prev => ({ ...prev, [pathKey]: [] }));
      }
    } finally {
      if (mountedRef.current) setFeatureLoadingPath(null);
    }
  }, [instanceId, featureRecordsByPath]);

  /**
   * Add a parent record + all its DIRECT child tables as FeatureDataSources.
   *
   * - Parent itself: recordFilter = { id: <recordId> }
   * - Each direct child table: recordFilter = { <child.parentKey>: <recordId> }
   *
   * Nested parent groups (e.g. CoachingSession under CoachingContext) are added
   * with the FK-only filter, scoping to "all sub-records of this ancestor".
   * Users can drill in further to add a specific sub-record.
   */
  const _addRecordWithChildren = useCallback(async (
    node: FeatureConnectionNode,
    parentTable: FeatureTableNode,
    record: ParentRecordNode,
    pathSegments: string[],
  ) => {
    const addKey = `${_pathKey(node.featureInstanceId, pathSegments)}|r:${parentTable.tableName}:${record.id}`;
    setAddingRecordPath(addKey);
    try {
      const allTables = node.tables || [];
      const parentLabel = `${parentTable.label || parentTable.tableName}: ${record.displayLabel}`;
      await api.post(`/api/workspace/${instanceId}/feature-datasources`, {
        featureInstanceId: node.featureInstanceId,
        featureCode: node.featureCode,
        tableName: parentTable.tableName,
        objectKey: parentTable.objectKey,
        label: parentLabel,
        recordFilter: { id: record.id },
      });

      const childTables = allTables.filter(t => t.parentTable === parentTable.tableName);
      for (const child of childTables) {
        if (!child.parentKey) continue;
        const childLabel = `${child.label || child.tableName}: ${record.displayLabel}`;
        await api.post(`/api/workspace/${instanceId}/feature-datasources`, {
          featureInstanceId: node.featureInstanceId,
          featureCode: node.featureCode,
          tableName: child.tableName,
          objectKey: child.objectKey,
          label: childLabel,
          recordFilter: { [child.parentKey]: record.id },
        });
      }

      _fetchFeatureDataSources();
      onSourcesChanged?.();
    } catch (err) {
      console.error('Failed to add record sources:', err);
    } finally {
      if (mountedRef.current) setAddingRecordPath(null);
    }
  }, [instanceId, _fetchFeatureDataSources, onSourcesChanged]);

  /* ── Check if a parent record is already added ── */
  const _isRecordAdded = useCallback((featureInstanceId: string, parentTableName: string, recordId: string): boolean => {
    return featureDataSources.some(fds =>
      fds.featureInstanceId === featureInstanceId
      && fds.tableName === parentTableName
      && fds.recordFilter?.id === recordId,
    );
  }, [featureDataSources]);

  /* ── Render ── */

  return (
    <div className={styles.sourcesTab} style={{ padding: 8, fontSize: 13 }}>
      {/* ── Browse Sources header ── */}
      <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 }}>
        <span style={{ fontSize: 11, fontWeight: 600, color: '#666', textTransform: 'uppercase' }}>
          {t('Quellen durchsuchen')}
        </span>
        <button
          onClick={_loadConnections}
          disabled={loadingRoot}
          style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, color: 'var(--primary-color, #F25843)' }}
        >
          {loadingRoot ? '...' : '\u21BB'}
        </button>
      </div>

      {/* ── Browse Sources tree ── */}
      {loadingRoot && tree.length === 0 && (
        <div style={{ fontSize: 12, color: '#999', textAlign: 'center', padding: 16 }}>
          {t('Verbindungen werden geladen…')}
        </div>
      )}

      {!loadingRoot && tree.length === 0 && (
        <div style={{ fontSize: 12, color: '#999', textAlign: 'center', padding: 16 }}>
          {t('Keine aktiven Verbindungen.')}
        </div>
      )}

      {tree.map(node => (
        <_TreeNodeView
          key={node.key}
          node={node}
          depth={0}
          onToggle={_toggleNode}
          onEnsureDs={_addAsDataSource}
          isAdded={_isAdded}
          addingPath={addingPath}
          dataSources={dataSources}
          onCycleScope={_cyclePersonalScope}
          onToggleNeutralize={_togglePersonalNeutralize}
          onSendToChat={_sendNodeToChat}
          scopeCycleTitle={_scopeCycleTitle}
          selectedKeys={selectedKeys}
          onSelect={_handleNodeSelect}
        />
      ))}

      {/* ── Divider ── */}
      <div style={{ height: 1, background: 'var(--border-color, #e0e0e0)', margin: '12px 0 8px' }} />

      {/* ── Feature Data header ── */}
      <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 }}>
        <span style={{ fontSize: 11, fontWeight: 600, color: '#666', textTransform: 'uppercase' }}>
          {t('Feature-Daten')}
        </span>
        <button
          onClick={_loadFeatureConnections}
          disabled={loadingFeatures}
          style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, color: '#7b1fa2' }}
        >
          {loadingFeatures ? '...' : '\u21BB'}
        </button>
      </div>

      {/* ── Feature Data tree ── */}
      {loadingFeatures && featureTree.length === 0 && (
        <div style={{ fontSize: 12, color: '#999', textAlign: 'center', padding: 16 }}>
          {t('Feature-Instanzen werden geladen…')}
        </div>
      )}

      {!loadingFeatures && featureTree.length === 0 && (
        <div style={{ fontSize: 12, color: '#999', textAlign: 'center', padding: 16 }}>
          {t('Keine Feature-Instanzen gefunden.')}
        </div>
      )}

      {featureTree.map(g => (
        <_MandateGroupView
          key={g.mandateId}
          group={g}
          onToggleGroup={_toggleMandateGroup}
          onToggleFeature={_toggleFeatureNode}
          onAddFeatureTable={_addFeatureTable}
          isTableAdded={_isFeatureTableAdded}
          addingKey={addingFeatureKey}
          featureExpandedPaths={featureExpandedPaths}
          featureRecordsByPath={featureRecordsByPath}
          featureLoadingPath={featureLoadingPath}
          addingRecordPath={addingRecordPath}
          onToggleFeaturePath={_toggleFeaturePath}
          onLoadRecordsAtPath={_loadRecordsAtPath}
          onAddRecordWithChildren={_addRecordWithChildren}
          isRecordAdded={_isRecordAdded}
          onSendToChat={onSendToChat_FeatureSource}
          featureDataSources={featureDataSources}
          onCycleScope={_cycleFeatureScope}
          onToggleNeutralize={_toggleFeatureNeutralize}
          onToggleNeutralizeField={_toggleNeutralizeField}
          featureTree={featureTree}
        />
      ))}
    </div>
  );
};

/* ─── TreeNodeView (recursive — Browse Sources side) ─────────────────── */

function _findDs(dataSources: UdbDataSource[], node: TreeNode): UdbDataSource | undefined {
  const expectedSourceType = node.service ? (_SERVICE_TO_SOURCE_TYPE[node.service] || node.service) : undefined;
  return dataSources.find(ds =>
    ds.connectionId === node.connectionId &&
    ds.path === (node.path || '/') &&
    (!expectedSourceType || ds.sourceType === expectedSourceType),
  );
}

interface _TreeNodeViewProps {
  node: TreeNode;
  depth: number;
  onToggle: (node: TreeNode) => void;
  onEnsureDs: (node: TreeNode) => Promise<string | null>;
  isAdded: (connectionId: string, service: string | undefined, path: string | undefined) => boolean;
  addingPath: string | null;
  dataSources: UdbDataSource[];
  onCycleScope: (ds: UdbDataSource) => void;
  onToggleNeutralize: (ds: UdbDataSource) => void;
  onSendToChat?: (params: { connectionId: string; sourceType: string; path: string; label: string; displayPath?: string }) => void;
  scopeCycleTitle: (scope: string) => string;
  selectedKeys: Set<string>;
  onSelect: (node: TreeNode, e: React.MouseEvent) => void;
  inheritedScope?: string;
  inheritedNeutralize?: boolean;
}

const _TreeNodeView: React.FC<_TreeNodeViewProps> = ({
  node, depth, onToggle, onEnsureDs, isAdded, addingPath,
  dataSources, onCycleScope, onToggleNeutralize, onSendToChat, scopeCycleTitle,
  selectedKeys, onSelect, inheritedScope, inheritedNeutralize,
}) => {
  const { t } = useLanguage();
  const [hovered, setHovered] = useState(false);
  const hasChildren = node.type !== 'file';
  const chevron = hasChildren
    ? (node.expanded ? '\u25BE' : '\u25B8')
    : '\u00A0\u00A0';
  const ds = _findDs(dataSources, node);

  const effectiveScope = ds?.scope ?? inheritedScope;
  const effectiveNeutralize = ds?.neutralize ?? inheritedNeutralize ?? false;
  const childInheritedScope = ds?.scope ?? inheritedScope;
  const childInheritedNeutralize = ds?.neutralize ?? inheritedNeutralize;

  const _dragPayload = {
    connectionId: node.connectionId,
    sourceType: node.service ? (_SERVICE_TO_SOURCE_TYPE[node.service] || node.service) : node.authority || '',
    path: node.path || '/',
    label: node.label,
    displayPath: node.displayPath || node.label,
    nodeType: node.type,
  };

  const _chatPayload = {
    connectionId: node.connectionId,
    sourceType: node.service ? (_SERVICE_TO_SOURCE_TYPE[node.service] || node.service) : node.authority || '',
    path: node.path || '/',
    label: node.label,
    displayPath: node.displayPath || node.label,
  };

  const connColor = ds ? _getSourceColor(ds.sourceType) : undefined;
  const isSelected = selectedKeys.has(node.key);

  return (
    <div>
      <div
        onClick={(e) => {
          if (e.ctrlKey || e.metaKey || e.shiftKey) {
            e.stopPropagation();
            onSelect(node, e);
          } else if (hasChildren) {
            onToggle(node);
          }
        }}
        onMouseEnter={() => setHovered(true)}
        onMouseLeave={() => setHovered(false)}
        draggable
        onDragStart={(e) => {
          e.stopPropagation();
          if (selectedKeys.size > 1 && isSelected) {
            const items = Array.from(selectedKeys).map(k => ({ key: k, ...(_dragPayload) }));
            e.dataTransfer.setData('application/datasource', JSON.stringify(items));
          } else {
            e.dataTransfer.setData('application/datasource', JSON.stringify(_dragPayload));
          }
          e.dataTransfer.setData('text/plain', node.label);
          e.dataTransfer.effectAllowed = 'copy';
        }}
        style={{
          display: 'flex',
          alignItems: 'center',
          gap: 4,
          // Compensate the 3px borderLeft on active rows with -3px paddingLeft so
          // the row content stays at exactly the same x-position as inactive rows.
          paddingLeft: (depth * 16 + 4) - (ds ? 3 : 0),
          paddingRight: 4,
          paddingTop: 3,
          paddingBottom: 3,
          cursor: hasChildren ? 'pointer' : 'default',
          borderRadius: 3,
          background: ds
            ? (hovered ? `${connColor}28` : `${connColor}10`)
            : isSelected
              ? 'var(--selection-bg, rgba(242, 88, 67, 0.12))'
              : (hovered ? 'var(--hover-bg, #f5f5f5)' : 'transparent'),
          borderLeft: ds ? `3px solid ${connColor}` : undefined,
          outline: isSelected && !ds ? '1px solid var(--primary-color, #F25843)' : undefined,
          transition: 'background 0.1s',
          userSelect: 'none',
        }}
      >
        <span style={{ fontSize: 10, color: '#888', width: 12, textAlign: 'center', flexShrink: 0 }}>
          {node.loading ? _Spinner() : chevron}
        </span>
        <span style={{ fontSize: 14, flexShrink: 0 }}>{node.icon}</span>
        <span style={{
          flex: 1, minWidth: 0, overflow: 'hidden',
          textOverflow: 'ellipsis', whiteSpace: 'nowrap',
          fontSize: 12,
          fontWeight: (node.type === 'connection' || ds) ? 600 : 400,
        }}>
          {node.label}
        </span>

        {/* ── Stable trio: chat | scope | neutralize (always in this order).
         * No "remove from workspace" button here by design: the UDB row only
         * exposes the catalog state. Detach from the *current chat* happens
         * via the chip "x" in WorkspaceInput; that chip is the single source
         * of truth for chat-scoped attachment lifecycle. */}
        <button
          onClick={e => { e.stopPropagation(); onSendToChat?.(_chatPayload); }}
          style={{
            background: 'none', border: 'none', cursor: 'pointer',
            fontSize: 13, padding: '0 2px', flexShrink: 0, lineHeight: 1, width: 22, textAlign: 'center',
            opacity: ds ? 0.7 : (hovered ? 0.5 : 0.25),
            color: 'var(--primary-color, #F25843)',
          }}
          title={t('In Chat senden')}
        >
          {'\u{1F4AC}'}
        </button>
        <button
          onClick={async (e) => {
            e.stopPropagation();
            if (ds) { onCycleScope(ds); return; }
            const newId = await onEnsureDs(node);
            if (newId) {
              try { await api.patch(`/api/datasources/${newId}/scope`, { scope: _nextScope(effectiveScope || 'personal') }); } catch {}
            }
          }}
          style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, padding: '0 2px', lineHeight: 1, flexShrink: 0, width: 22, textAlign: 'center', opacity: ds ? 1 : 0.35 }}
          title={ds ? scopeCycleTitle(ds.scope) : (effectiveScope ? `${t('Geerbt')}: ${effectiveScope}` : t('Scope setzen'))}
        >
          {_SCOPE_ICONS[ds?.scope || effectiveScope || 'personal']}
        </button>
        <button
          onClick={async (e) => {
            e.stopPropagation();
            if (ds) { onToggleNeutralize(ds); return; }
            const newId = await onEnsureDs(node);
            if (newId) {
              try { await api.patch(`/api/datasources/${newId}/neutralize`, { neutralize: !effectiveNeutralize }); } catch {}
            }
          }}
          style={{
            background: 'none', border: 'none', cursor: 'pointer',
            fontSize: 12, padding: '0 2px', lineHeight: 1, flexShrink: 0, width: 22, textAlign: 'center',
            opacity: (ds?.neutralize ?? effectiveNeutralize) ? 1 : 0.35,
          }}
          title={(ds?.neutralize ?? effectiveNeutralize) ? t('Neutralisierung an') : t('Neutralisierung aus')}
        >
          {'\uD83D\uDD12'}
        </button>

      </div>

      {node.expanded && node.children && node.children.length > 0 && (
        <div>
          {node.children.map(child => (
            <_TreeNodeView
              key={child.key}
              node={child}
              depth={depth + 1}
              onToggle={onToggle}
              onEnsureDs={onEnsureDs}
              isAdded={isAdded}
              addingPath={addingPath}
              dataSources={dataSources}
              onCycleScope={onCycleScope}
              onToggleNeutralize={onToggleNeutralize}
              onSendToChat={onSendToChat}
              scopeCycleTitle={scopeCycleTitle}
              selectedKeys={selectedKeys}
              onSelect={onSelect}
              inheritedScope={childInheritedScope}
              inheritedNeutralize={childInheritedNeutralize}
            />
          ))}
        </div>
      )}

      {node.expanded && node.children && node.children.length === 0 && !node.loading && (
        <div style={{ paddingLeft: (depth + 1) * 16 + 20, fontSize: 11, color: '#bbb', padding: '2px 0 2px ' + ((depth + 1) * 16 + 20) + 'px' }}>
          {t('(leer)')}
        </div>
      )}
    </div>
  );
};

/* ─── Feature-side action props (shared) ─────────────────────────────── */

interface _FeatureActionContext {
  featureExpandedPaths: Set<string>;
  featureRecordsByPath: Record<string, ParentRecordNode[]>;
  featureLoadingPath: string | null;
  addingRecordPath: string | null;
  onToggleFeaturePath: (pathKey: string) => void;
  onLoadRecordsAtPath: (
    node: FeatureConnectionNode,
    table: FeatureTableNode,
    parentPathSegments: string[],
  ) => void;
  onAddRecordWithChildren: (
    node: FeatureConnectionNode,
    parentTable: FeatureTableNode,
    record: ParentRecordNode,
    pathSegments: string[],
  ) => void;
  isRecordAdded: (featureInstanceId: string, parentTableName: string, recordId: string) => boolean;
  onAddFeatureTable: (
    node: FeatureConnectionNode,
    table: FeatureTableNode,
    extra?: { recordFilter?: Record<string, string>; labelOverride?: string },
  ) => Promise<string | null>;
  isTableAdded: (featureInstanceId: string, tableName: string) => boolean;
  addingKey: string | null;
  onSendToChat?: (params: { featureInstanceId: string; featureCode: string; tableName?: string; objectKey: string; label: string; fieldName?: string }) => void;
  featureDataSources: UdbFeatureDataSource[];
  onCycleScope: (fds: UdbFeatureDataSource) => void;
  onToggleNeutralize: (fds: UdbFeatureDataSource) => void;
  onToggleNeutralizeField: (fds: UdbFeatureDataSource, fieldName: string) => void;
  featureTree: MandateGroupNode[];
}

/* ─── MandateGroupView (mandate + feature instances) ─────────────────── */

interface _MandateGroupViewProps extends _FeatureActionContext {
  group: MandateGroupNode;
  onToggleGroup: (mandateId: string) => void;
  onToggleFeature: (node: FeatureConnectionNode) => void;
}

const _MandateGroupView: React.FC<_MandateGroupViewProps> = (props) => {
  const { group, onToggleGroup, onToggleFeature, ...ctx } = props;
  const [hovered, setHovered] = useState(false);
  const chevron = group.expanded ? '\u25BE' : '\u25B8';

  return (
    <div>
      <div
        onClick={() => onToggleGroup(group.mandateId)}
        onMouseEnter={() => setHovered(true)}
        onMouseLeave={() => setHovered(false)}
        style={{
          display: 'flex', alignItems: 'center', gap: 4,
          paddingLeft: 4, paddingRight: 4, paddingTop: 3, paddingBottom: 3,
          cursor: 'pointer', borderRadius: 3,
          background: hovered ? 'var(--hover-bg, #f5f5f5)' : 'transparent',
          transition: 'background 0.1s', userSelect: 'none',
        }}
      >
        <span style={{ fontSize: 10, color: '#888', width: 12, textAlign: 'center', flexShrink: 0 }}>
          {chevron}
        </span>
        <span style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontSize: 12, fontWeight: 700, color: '#555' }}>
          {group.mandateLabel}
        </span>
      </div>

      {group.expanded && (
        <div style={{ paddingLeft: 10 }}>
          {group.featureConnections.map(fNode => (
            <_FeatureNodeView
              key={fNode.featureInstanceId}
              node={fNode}
              onToggle={onToggleFeature}
              {...ctx}
            />
          ))}
        </div>
      )}
    </div>
  );
};

/* ─── FeatureNodeView (feature instance + recursive items) ───────────── */

interface _FeatureNodeViewProps extends _FeatureActionContext {
  node: FeatureConnectionNode;
  onToggle: (node: FeatureConnectionNode) => void;
}

const _FeatureNodeView: React.FC<_FeatureNodeViewProps> = (props) => {
  const { node, onToggle, ...ctx } = props;
  const { t } = useLanguage();
  const [hovered, setHovered] = useState(false);
  const chevron = node.expanded ? '\u25BE' : '\u25B8';

  const wildcardFds = ctx.featureDataSources.find(
    f => f.featureInstanceId === node.featureInstanceId && f.tableName === '*' && !f.recordFilter,
  );

  const topItems = useMemo(
    () => _buildTopFeatureTree(node.tables || []),
    [node.tables],
  );

  return (
    <div>
      <div
        onClick={() => onToggle(node)}
        onMouseEnter={() => setHovered(true)}
        onMouseLeave={() => setHovered(false)}
        draggable
        onDragStart={(e) => {
          e.stopPropagation();
          const payload = JSON.stringify({
            featureInstanceId: node.featureInstanceId,
            featureCode: node.featureCode,
            objectKey: `data.feature.${node.featureCode}.*`,
            label: node.label,
          });
          e.dataTransfer.setData('application/feature-source', payload);
          e.dataTransfer.setData('text/plain', node.label);
          e.dataTransfer.effectAllowed = 'copy';
        }}
        style={{
          display: 'flex', alignItems: 'center', gap: 4,
          // Compensate the 3px borderLeft on active wildcard rows with -3px
          // paddingLeft so the row content stays at the same x-position.
          paddingLeft: wildcardFds ? 1 : 4,
          paddingRight: 4, paddingTop: 3, paddingBottom: 3,
          cursor: 'pointer', borderRadius: 3,
          background: wildcardFds
            ? (hovered ? '#ede7f6' : '#7b1fa208')
            : (hovered ? 'var(--hover-bg, #f5f5f5)' : 'transparent'),
          borderLeft: wildcardFds ? '3px solid #7b1fa2' : undefined,
          transition: 'background 0.1s', userSelect: 'none',
        }}
      >
        <span style={{ fontSize: 10, color: '#888', width: 12, textAlign: 'center', flexShrink: 0 }}>
          {node.loading ? _Spinner() : chevron}
        </span>
        <span style={{ fontSize: 13, flexShrink: 0, display: 'flex', alignItems: 'center', color: '#7b1fa2' }}>
          {getPageIcon(`feature.${node.featureCode}`) || '\uD83D\uDDC3\uFE0F'}
        </span>
        <span style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontSize: 12, fontWeight: 600 }}>
          {node.label}
        </span>
        <span style={{ fontSize: 10, color: '#999', flexShrink: 0 }}>
          {node.tableCount} {t('Tabellen')}
        </span>

        <button
          onClick={(e) => {
            e.stopPropagation();
            ctx.onSendToChat?.({
              featureInstanceId: node.featureInstanceId,
              featureCode: node.featureCode,
              objectKey: `data.feature.${node.featureCode}.*`,
              label: node.label,
            });
          }}
          style={{
            background: 'none', border: 'none', cursor: 'pointer',
            fontSize: 14, padding: '0 2px', flexShrink: 0, lineHeight: 1, width: 22, textAlign: 'center',
            opacity: wildcardFds ? 0.7 : (hovered ? 0.5 : 0.25), color: '#7b1fa2',
          }}
          title={t('Alle Tabellen in Chat senden')}
        >
          {'\u{1F4AC}'}
        </button>
        <button
          onClick={async (e) => {
            e.stopPropagation();
            if (wildcardFds) { ctx.onCycleScope(wildcardFds); return; }
            const newId = await ctx.onAddFeatureTable(node, { objectKey: `data.feature.${node.featureCode}.*`, tableName: '*', label: node.label, fields: [] } as FeatureTableNode);
            if (newId) {
              try { await api.patch(`/api/datasources/${newId}/scope`, { scope: _nextScope('personal') }); } catch {}
            }
          }}
          style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, padding: '0 2px', lineHeight: 1, flexShrink: 0, width: 22, textAlign: 'center', opacity: wildcardFds ? 1 : 0.35 }}
          title={wildcardFds ? `${t('Bereich')}: ${wildcardFds.scope}` : t('Scope setzen')}
        >
          {_SCOPE_ICONS[wildcardFds?.scope || 'personal']}
        </button>
        <button
          onClick={async (e) => {
            e.stopPropagation();
            if (wildcardFds) { ctx.onToggleNeutralize(wildcardFds); return; }
            const newId = await ctx.onAddFeatureTable(node, { objectKey: `data.feature.${node.featureCode}.*`, tableName: '*', label: node.label, fields: [] } as FeatureTableNode);
            if (newId) {
              try { await api.patch(`/api/datasources/${newId}/neutralize`, { neutralize: true }); } catch {}
            }
          }}
          style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, padding: '0 2px', lineHeight: 1, flexShrink: 0, width: 22, textAlign: 'center', opacity: wildcardFds ? (wildcardFds.neutralize ? 1 : 0.35) : 0.35 }}
          title={wildcardFds?.neutralize ? t('Neutralisierung an') : t('Neutralisierung aus')}
        >
          {'\uD83D\uDD12'}
        </button>

      </div>

      {node.expanded && topItems.length > 0 && (
        <div>
          {topItems.map((item, idx) => (
            <_FeatureItemView
              key={_itemKey(item, idx)}
              featureNode={node}
              item={item}
              pathSegments={[]}
              depth={1}
              inheritedScope={wildcardFds?.scope}
              inheritedNeutralize={wildcardFds?.neutralize}
              {...ctx}
            />
          ))}
        </div>
      )}

      {node.expanded && (node.tables?.length ?? 0) === 0 && !node.loading && (
        <div style={{ paddingLeft: 36, fontSize: 11, color: '#bbb', padding: '2px 0 2px 36px' }}>
          {t('(keine Tabellen)')}
        </div>
      )}
    </div>
  );
};

function _itemKey(item: FeatureItem, idx: number): string {
  if (item.kind === 'group') return `g:${item.objectKey}-${idx}`;
  if (item.kind === 'parentGroup') return `p:${item.table.tableName}-${idx}`;
  return `t:${item.table.tableName}-${idx}`;
}

/* ─── FeatureItemView (recursive — handles group / parentGroup / table) ── */

interface _FeatureItemViewProps extends _FeatureActionContext {
  featureNode: FeatureConnectionNode;
  item: FeatureItem;
  pathSegments: string[];
  depth: number;
  inheritedScope?: string;
  inheritedNeutralize?: boolean;
}

const _FeatureItemView: React.FC<_FeatureItemViewProps> = (props) => {
  const { featureNode, item, pathSegments, depth, inheritedScope, inheritedNeutralize, ...ctx } = props;

  if (item.kind === 'group') {
    return (
      <_GroupFolderView
        featureNode={featureNode}
        objectKey={item.objectKey}
        label={item.label}
        items={item.items}
        pathSegments={pathSegments}
        depth={depth}
        inheritedScope={inheritedScope}
        inheritedNeutralize={inheritedNeutralize}
        {...ctx}
      />
    );
  }
  if (item.kind === 'parentGroup') {
    return (
      <_ParentGroupView
        featureNode={featureNode}
        table={item.table}
        pathSegments={pathSegments}
        depth={depth}
        inheritedScope={inheritedScope}
        inheritedNeutralize={inheritedNeutralize}
        {...ctx}
      />
    );
  }
  return (
    <_FeatureTableRow
      featureNode={featureNode}
      table={item.table}
      depth={depth}
      onAddFeatureTable={ctx.onAddFeatureTable}
      onSendToChat={ctx.onSendToChat}
      featureDataSources={ctx.featureDataSources}
      onCycleScope={ctx.onCycleScope}
      onToggleNeutralize={ctx.onToggleNeutralize}
      onToggleNeutralizeField={ctx.onToggleNeutralizeField}
      featureTree={ctx.featureTree}
      inheritedScope={inheritedScope}
      inheritedNeutralize={inheritedNeutralize}
    />
  );
};

/* ─── GroupFolderView (categorical folder) ───────────────────────────── */

interface _GroupFolderViewProps extends _FeatureActionContext {
  featureNode: FeatureConnectionNode;
  objectKey: string;
  label: string;
  items: FeatureItem[];
  pathSegments: string[];
  depth: number;
  inheritedScope?: string;
  inheritedNeutralize?: boolean;
}

const _GroupFolderView: React.FC<_GroupFolderViewProps> = (props) => {
  const { featureNode, objectKey, label, items, pathSegments, depth, inheritedScope, inheritedNeutralize, ...ctx } = props;
  const { t } = useLanguage();
  const [hovered, setHovered] = useState(false);

  const segments = [...pathSegments, `g:${objectKey}`];
  const pathKey = _pathKey(featureNode.featureInstanceId, segments);
  const expanded = ctx.featureExpandedPaths.has(pathKey);
  const chevron = expanded ? '\u25BE' : '\u25B8';

  // Container-wildcard objectKey: matches every record/table inside this group.
  // Pattern lives in the backend workspaceContext-resolver -- the trailing `.*`
  // is treated as a glob-prefix so a single FDS row drives chat/scope/neutralize
  // for every child without having to add each one individually.
  const containerObjectKey = `data.feature.${featureNode.featureCode}.group:${objectKey}.*`;
  const wildcardFds = ctx.featureDataSources.find(
    f => f.featureInstanceId === featureNode.featureInstanceId && f.objectKey === containerObjectKey,
  );
  const _chatPayload = {
    featureInstanceId: featureNode.featureInstanceId,
    featureCode: featureNode.featureCode,
    objectKey: containerObjectKey,
    label,
  };

  return (
    <div>
      <div
        onClick={() => ctx.onToggleFeaturePath(pathKey)}
        onMouseEnter={() => setHovered(true)}
        onMouseLeave={() => setHovered(false)}
        draggable
        onDragStart={(e) => {
          e.stopPropagation();
          e.dataTransfer.setData('application/feature-source', JSON.stringify(_chatPayload));
          e.dataTransfer.setData('text/plain', label);
          e.dataTransfer.effectAllowed = 'copy';
        }}
        style={{
          display: 'flex', alignItems: 'center', gap: 4,
          // Compensate the 3px border on active wildcard rows so the row
          // content stays at the same x-position whether or not it's active.
          paddingLeft: (depth * 16 + 4) - (wildcardFds ? 3 : 0),
          paddingRight: 4, paddingTop: 3, paddingBottom: 3,
          cursor: 'pointer', borderRadius: 3,
          background: wildcardFds
            ? (hovered ? '#ede7f6' : '#7b1fa208')
            : (hovered ? 'var(--hover-bg, #f5f5f5)' : 'transparent'),
          borderLeft: wildcardFds ? '3px solid #7b1fa2' : undefined,
          transition: 'background 0.1s', userSelect: 'none',
        }}
      >
        <span style={{ fontSize: 10, color: '#888', width: 12, textAlign: 'center', flexShrink: 0 }}>
          {chevron}
        </span>
        <span style={{ fontSize: 13, flexShrink: 0 }}>{'\uD83D\uDCC1'}</span>
        <span style={{
          flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
          fontSize: 12, fontWeight: 600, color: '#555',
          textTransform: 'uppercase', letterSpacing: 0.3,
        }}>
          {label}
        </span>

        <button
          onClick={(e) => { e.stopPropagation(); ctx.onSendToChat?.(_chatPayload); }}
          style={{
            background: 'none', border: 'none', cursor: 'pointer',
            fontSize: 14, padding: '0 2px', flexShrink: 0, lineHeight: 1, width: 22, textAlign: 'center',
            opacity: wildcardFds ? 0.7 : (hovered ? 0.5 : 0.25), color: '#7b1fa2',
          }}
          title={t('Container in Chat senden')}
        >
          {'\u{1F4AC}'}
        </button>
        <button
          onClick={async (e) => {
            e.stopPropagation();
            if (wildcardFds) { ctx.onCycleScope(wildcardFds); return; }
            const newId = await ctx.onAddFeatureTable(
              featureNode,
              { objectKey: containerObjectKey, tableName: '*', label, fields: [] } as FeatureTableNode,
            );
            if (newId) {
              try { await api.patch(`/api/datasources/${newId}/scope`, { scope: _nextScope(inheritedScope || 'personal') }); } catch {}
            }
          }}
          style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, padding: '0 2px', lineHeight: 1, flexShrink: 0, width: 22, textAlign: 'center', opacity: wildcardFds ? 1 : 0.35 }}
          title={wildcardFds ? `${t('Bereich')}: ${wildcardFds.scope}` : (inheritedScope ? `${t('Geerbt')}: ${inheritedScope}` : t('Scope setzen'))}
        >
          {_SCOPE_ICONS[wildcardFds?.scope || inheritedScope || 'personal']}
        </button>
        <button
          onClick={async (e) => {
            e.stopPropagation();
            if (wildcardFds) { ctx.onToggleNeutralize(wildcardFds); return; }
            const newId = await ctx.onAddFeatureTable(
              featureNode,
              { objectKey: containerObjectKey, tableName: '*', label, fields: [] } as FeatureTableNode,
            );
            if (newId) {
              try { await api.patch(`/api/datasources/${newId}/neutralize`, { neutralize: true }); } catch {}
            }
          }}
          style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, padding: '0 2px', lineHeight: 1, flexShrink: 0, width: 22, textAlign: 'center', opacity: (wildcardFds?.neutralize ?? inheritedNeutralize) ? 1 : 0.35 }}
          title={(wildcardFds?.neutralize ?? inheritedNeutralize) ? t('Neutralisierung an') : t('Neutralisierung aus')}
        >
          {'\uD83D\uDD12'}
        </button>
      </div>

      {expanded && items.length > 0 && (
        <div>
          {items.map((sub, idx) => (
            <_FeatureItemView
              key={_itemKey(sub, idx)}
              featureNode={featureNode}
              item={sub}
              pathSegments={segments}
              depth={depth + 1}
              inheritedScope={inheritedScope}
              inheritedNeutralize={inheritedNeutralize}
              {...ctx}
            />
          ))}
        </div>
      )}
    </div>
  );
};

/* ─── ParentGroupView (parent table → list of records) ───────────────── */

interface _ParentGroupViewProps extends _FeatureActionContext {
  featureNode: FeatureConnectionNode;
  table: FeatureTableNode;
  pathSegments: string[];
  depth: number;
  inheritedScope?: string;
  inheritedNeutralize?: boolean;
}

const _ParentGroupView: React.FC<_ParentGroupViewProps> = (props) => {
  const { featureNode, table, pathSegments, depth, inheritedScope, inheritedNeutralize, ...ctx } = props;
  const { t } = useLanguage();
  const [hovered, setHovered] = useState(false);

  const segments = [...pathSegments, `p:${table.tableName}`];
  const pathKey = _pathKey(featureNode.featureInstanceId, segments);
  const expanded = ctx.featureExpandedPaths.has(pathKey);
  const loading = ctx.featureLoadingPath === pathKey;
  const records = ctx.featureRecordsByPath[pathKey];
  const chevron = expanded ? '\u25BE' : '\u25B8';

  const childTables = (featureNode.tables || []).filter(c => c.parentTable === table.tableName);

  const _onToggle = () => {
    const willExpand = !expanded;
    ctx.onToggleFeaturePath(pathKey);
    if (willExpand && !records) {
      ctx.onLoadRecordsAtPath(featureNode, table, pathSegments);
    }
  };

  // Container-wildcard objectKey for the parent group: matches every record in
  // ``table`` so a single FDS row drives chat/scope/neutralize for the whole list.
  const containerObjectKey = `data.feature.${featureNode.featureCode}.${table.tableName}.*`;
  const wildcardFds = ctx.featureDataSources.find(
    f => f.featureInstanceId === featureNode.featureInstanceId
      && f.tableName === table.tableName
      && !f.recordFilter
      && f.objectKey === containerObjectKey,
  );
  const _chatPayload = {
    featureInstanceId: featureNode.featureInstanceId,
    featureCode: featureNode.featureCode,
    tableName: table.tableName,
    objectKey: containerObjectKey,
    label: table.label || table.tableName,
  };

  return (
    <div>
      <div
        onClick={_onToggle}
        onMouseEnter={() => setHovered(true)}
        onMouseLeave={() => setHovered(false)}
        draggable
        onDragStart={(e) => {
          e.stopPropagation();
          e.dataTransfer.setData('application/feature-source', JSON.stringify(_chatPayload));
          e.dataTransfer.setData('text/plain', _chatPayload.label);
          e.dataTransfer.effectAllowed = 'copy';
        }}
        style={{
          display: 'flex', alignItems: 'center', gap: 4,
          paddingLeft: (depth * 16 + 4) - (wildcardFds ? 3 : 0),
          paddingRight: 4, paddingTop: 3, paddingBottom: 3,
          cursor: 'pointer', borderRadius: 3,
          background: wildcardFds
            ? (hovered ? '#ede7f6' : '#7b1fa208')
            : (hovered ? 'var(--hover-bg, #f5f5f5)' : 'transparent'),
          borderLeft: wildcardFds ? '3px solid #7b1fa2' : undefined,
          transition: 'background 0.1s', userSelect: 'none',
        }}
      >
        <span style={{ fontSize: 10, color: '#888', width: 12, textAlign: 'center', flexShrink: 0 }}>
          {loading ? _Spinner() : chevron}
        </span>
        <span style={{ fontSize: 13, flexShrink: 0 }}>{'\uD83D\uDCC2'}</span>
        <span style={{
          flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
          fontSize: 12, fontWeight: 600, color: '#555',
        }}>
          {table.label || table.tableName}
        </span>
        {childTables.length > 0 && (
          <span style={{ fontSize: 10, color: '#999', flexShrink: 0 }}>
            +{childTables.length} {t('Tabellen')}
          </span>
        )}

        <button
          onClick={(e) => { e.stopPropagation(); ctx.onSendToChat?.(_chatPayload); }}
          style={{
            background: 'none', border: 'none', cursor: 'pointer',
            fontSize: 14, padding: '0 2px', flexShrink: 0, lineHeight: 1, width: 22, textAlign: 'center',
            opacity: wildcardFds ? 0.7 : (hovered ? 0.5 : 0.25), color: '#7b1fa2',
          }}
          title={t('Container in Chat senden')}
        >
          {'\u{1F4AC}'}
        </button>
        <button
          onClick={async (e) => {
            e.stopPropagation();
            if (wildcardFds) { ctx.onCycleScope(wildcardFds); return; }
            const newId = await ctx.onAddFeatureTable(
              featureNode,
              { ...table, objectKey: containerObjectKey } as FeatureTableNode,
              { labelOverride: _chatPayload.label },
            );
            if (newId) {
              try { await api.patch(`/api/datasources/${newId}/scope`, { scope: _nextScope(inheritedScope || 'personal') }); } catch {}
            }
          }}
          style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, padding: '0 2px', lineHeight: 1, flexShrink: 0, width: 22, textAlign: 'center', opacity: wildcardFds ? 1 : 0.35 }}
          title={wildcardFds ? `${t('Bereich')}: ${wildcardFds.scope}` : (inheritedScope ? `${t('Geerbt')}: ${inheritedScope}` : t('Scope setzen'))}
        >
          {_SCOPE_ICONS[wildcardFds?.scope || inheritedScope || 'personal']}
        </button>
        <button
          onClick={async (e) => {
            e.stopPropagation();
            if (wildcardFds) { ctx.onToggleNeutralize(wildcardFds); return; }
            const newId = await ctx.onAddFeatureTable(
              featureNode,
              { ...table, objectKey: containerObjectKey } as FeatureTableNode,
              { labelOverride: _chatPayload.label },
            );
            if (newId) {
              try { await api.patch(`/api/datasources/${newId}/neutralize`, { neutralize: true }); } catch {}
            }
          }}
          style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, padding: '0 2px', lineHeight: 1, flexShrink: 0, width: 22, textAlign: 'center', opacity: (wildcardFds?.neutralize ?? inheritedNeutralize) ? 1 : 0.35 }}
          title={(wildcardFds?.neutralize ?? inheritedNeutralize) ? t('Neutralisierung an') : t('Neutralisierung aus')}
        >
          {'\uD83D\uDD12'}
        </button>
      </div>

      {expanded && records && records.length > 0 && (
        <div>
          {records.map(record => (
            <_RecordRowView
              key={record.id}
              featureNode={featureNode}
              parentTable={table}
              record={record}
              pathSegments={segments}
              depth={depth + 1}
              inheritedScope={inheritedScope}
              inheritedNeutralize={inheritedNeutralize}
              {...ctx}
            />
          ))}
        </div>
      )}

      {expanded && records && records.length === 0 && !loading && (
        <div style={{
          paddingLeft: (depth + 1) * 16 + 20, fontSize: 11, color: '#bbb',
          padding: '2px 0 2px ' + ((depth + 1) * 16 + 20) + 'px',
        }}>
          {t('(keine Einträge)')}
        </div>
      )}
    </div>
  );
};

/* ─── RecordRowView (single record + recursive children when expanded) ── */

interface _RecordRowViewProps extends _FeatureActionContext {
  featureNode: FeatureConnectionNode;
  parentTable: FeatureTableNode;
  record: ParentRecordNode;
  pathSegments: string[];
  depth: number;
  inheritedScope?: string;
  inheritedNeutralize?: boolean;
}

const _RecordRowView: React.FC<_RecordRowViewProps> = (props) => {
  const { featureNode, parentTable, record, pathSegments, depth, inheritedScope, inheritedNeutralize, ...ctx } = props;
  const { t } = useLanguage();
  const [hovered, setHovered] = useState(false);

  const segments = [...pathSegments, `r:${parentTable.tableName}:${record.id}`];
  const pathKey = _pathKey(featureNode.featureInstanceId, segments);
  const expanded = ctx.featureExpandedPaths.has(pathKey);
  const chevron = expanded ? '\u25BE' : '\u25B8';

  const fds = ctx.featureDataSources.find(
    f => f.featureInstanceId === featureNode.featureInstanceId
      && f.tableName === parentTable.tableName
      && f.recordFilter?.id === record.id,
  );

  const childItems = useMemo(
    () => _childrenForRecord(featureNode.tables || [], parentTable.tableName),
    [featureNode.tables, parentTable.tableName],
  );

  const isAdded = ctx.isRecordAdded(featureNode.featureInstanceId, parentTable.tableName, record.id);
  const addingKey = `${_pathKey(featureNode.featureInstanceId, pathSegments)}|r:${parentTable.tableName}:${record.id}`;
  const isAdding = ctx.addingRecordPath === addingKey;

  const _chatPayload = {
    featureInstanceId: featureNode.featureInstanceId,
    featureCode: featureNode.featureCode,
    tableName: parentTable.tableName,
    objectKey: parentTable.objectKey,
    label: `${parentTable.label || parentTable.tableName}: ${record.displayLabel}`,
  };

  return (
    <div>
      <div
        onClick={() => ctx.onToggleFeaturePath(pathKey)}
        onMouseEnter={() => setHovered(true)}
        onMouseLeave={() => setHovered(false)}
        draggable
        onDragStart={(e) => {
          e.stopPropagation();
          const payload = JSON.stringify({
            featureInstanceId: featureNode.featureInstanceId,
            featureCode: featureNode.featureCode,
            objectKey: parentTable.objectKey,
            label: `${parentTable.label || parentTable.tableName}: ${record.displayLabel}`,
          });
          e.dataTransfer.setData('application/feature-source', payload);
          e.dataTransfer.setData('text/plain', record.displayLabel);
          e.dataTransfer.effectAllowed = 'copy';
        }}
        style={{
          display: 'flex', alignItems: 'center', gap: 4,
          paddingLeft: depth * 16 + 4, paddingRight: 4, paddingTop: 3, paddingBottom: 3,
          cursor: 'pointer', borderRadius: 3,
          background: fds
            ? (hovered ? '#ede7f6' : '#7b1fa208')
            : (hovered ? 'var(--hover-bg, #f5f5f5)' : 'transparent'),
          transition: 'background 0.1s', userSelect: 'none',
        }}
        title={Object.entries(record.fields).map(([k, v]) => `${k}: ${v}`).join(', ')}
      >
        <span style={{ fontSize: 10, color: '#888', width: 12, textAlign: 'center', flexShrink: 0 }}>
          {chevron}
        </span>
        <span style={{ fontSize: 13, flexShrink: 0 }}>{'\uD83D\uDCCB'}</span>
        <span style={{
          flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
          fontSize: 12, fontWeight: fds ? 600 : 400,
        }}>
          {record.displayLabel}
        </span>

        {/* Add record + direct children as data sources (only when not already added). */}
        {!fds && !isAdded && (
          <button
            onClick={(e) => {
              e.stopPropagation();
              ctx.onAddRecordWithChildren(featureNode, parentTable, record, pathSegments);
            }}
            disabled={isAdding}
            style={{
              background: 'none', border: 'none', cursor: isAdding ? 'wait' : 'pointer',
              fontSize: 12, color: '#7b1fa2', padding: '0 2px', flexShrink: 0,
              opacity: hovered ? 1 : 0.5,
            }}
            title={t('Datensatz + Kind-Tabellen als Quelle hinzufügen')}
          >
            {isAdding ? '\u23F3' : '+'}
          </button>
        )}

        <button
          onClick={(e) => { e.stopPropagation(); ctx.onSendToChat?.(_chatPayload); }}
          style={{
            background: 'none', border: 'none', cursor: 'pointer',
            fontSize: 13, padding: '0 2px', flexShrink: 0, lineHeight: 1, width: 22, textAlign: 'center',
            opacity: fds ? 0.7 : (hovered ? 0.5 : 0.25), color: '#7b1fa2',
          }}
          title={t('In Chat senden')}
        >
          {'\u{1F4AC}'}
        </button>
        {fds ? (
          <button
            onClick={(e) => { e.stopPropagation(); ctx.onCycleScope(fds); }}
            style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, padding: '0 2px', lineHeight: 1, flexShrink: 0, width: 22, textAlign: 'center' }}
            title={`${t('Bereich')}: ${fds.scope}`}
          >
            {_SCOPE_ICONS[fds.scope] || _SCOPE_ICONS.personal}
          </button>
        ) : (
          <span
            style={{ fontSize: 12, padding: '0 2px', lineHeight: 1, flexShrink: 0, width: 22, textAlign: 'center', opacity: 0.25, display: 'inline-block' }}
            title={inheritedScope ? `${t('Geerbt')}: ${inheritedScope}` : t('Kein Scope gesetzt')}
          >
            {_SCOPE_ICONS[inheritedScope || 'personal']}
          </span>
        )}
        {fds ? (
          <button
            onClick={(e) => { e.stopPropagation(); ctx.onToggleNeutralize(fds); }}
            style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, padding: '0 2px', lineHeight: 1, flexShrink: 0, width: 22, textAlign: 'center', opacity: fds.neutralize ? 1 : 0.35 }}
            title={fds.neutralize ? t('Neutralisierung an') : t('Neutralisierung aus')}
          >
            {'\uD83D\uDD12'}
          </button>
        ) : (
          <span
            style={{ fontSize: 12, padding: '0 2px', lineHeight: 1, flexShrink: 0, width: 22, textAlign: 'center', opacity: (inheritedNeutralize ?? false) ? 0.5 : 0.15, display: 'inline-block' }}
            title={(inheritedNeutralize ?? false) ? t('Neutralisierung geerbt') : t('Neutralisierung aus')}
          >
            {'\uD83D\uDD12'}
          </span>
        )}
      </div>

      {expanded && childItems.length > 0 && (
        <div>
          {childItems.map((sub, idx) => (
            <_FeatureItemView
              key={_itemKey(sub, idx)}
              featureNode={featureNode}
              item={sub}
              pathSegments={segments}
              depth={depth + 1}
              inheritedScope={fds?.scope ?? inheritedScope}
              inheritedNeutralize={fds?.neutralize ?? inheritedNeutralize}
              {...ctx}
            />
          ))}
        </div>
      )}
    </div>
  );
};

/* ─── FeatureTableRow (leaf table) ───────────────────────────────────── */

interface _FeatureTableRowProps {
  featureNode: FeatureConnectionNode;
  table: FeatureTableNode;
  depth: number;
  onAddFeatureTable: (
    node: FeatureConnectionNode,
    table: FeatureTableNode,
    extra?: { recordFilter?: Record<string, string>; labelOverride?: string },
  ) => Promise<string | null>;
  onSendToChat?: (params: { featureInstanceId: string; featureCode: string; tableName?: string; objectKey: string; label: string; fieldName?: string }) => void;
  featureDataSources: UdbFeatureDataSource[];
  onCycleScope: (fds: UdbFeatureDataSource) => void;
  onToggleNeutralize: (fds: UdbFeatureDataSource) => void;
  onToggleNeutralizeField: (fds: UdbFeatureDataSource, fieldName: string) => void;
  featureTree: MandateGroupNode[];
  inheritedScope?: string;
  inheritedNeutralize?: boolean;
}

const _FeatureTableRow: React.FC<_FeatureTableRowProps> = ({
  featureNode, table, depth, onAddFeatureTable, onSendToChat,
  featureDataSources, onCycleScope, onToggleNeutralize, onToggleNeutralizeField,
  featureTree, inheritedScope, inheritedNeutralize,
}) => {
  const { t } = useLanguage();
  const [hovered, setHovered] = useState(false);
  const [fieldsExpanded, setFieldsExpanded] = useState(false);
  const tableLabel = table.label || table.tableName;

  const fds = featureDataSources.find(
    f => f.featureInstanceId === featureNode.featureInstanceId
      && f.tableName === table.tableName
      && !f.recordFilter,
  );

  const effectiveScope = fds?.scope ?? inheritedScope;
  const effectiveNeutralize = fds?.neutralize ?? inheritedNeutralize ?? false;
  const _chatPayload = {
    featureInstanceId: featureNode.featureInstanceId,
    featureCode: featureNode.featureCode,
    tableName: table.tableName,
    objectKey: table.objectKey,
    label: tableLabel,
  };

  const resolvedFields = featureTree
    ? _findTableFields(featureTree, featureNode.featureInstanceId, table.tableName)
    : table.fields;
  const neutralizedCount = fds?.neutralizeFields?.length ?? 0;

  return (
    <div>
      <div
        onMouseEnter={() => setHovered(true)}
        onMouseLeave={() => setHovered(false)}
        draggable
        onDragStart={(e) => {
          e.stopPropagation();
          e.dataTransfer.setData('application/feature-source', JSON.stringify(_chatPayload));
          e.dataTransfer.setData('text/plain', tableLabel);
          e.dataTransfer.effectAllowed = 'copy';
        }}
        style={{
          display: 'flex', alignItems: 'center', gap: 4,
          paddingLeft: depth * 16 + 4, paddingRight: 4, paddingTop: 3, paddingBottom: 3,
          borderRadius: 3,
          background: fds
            ? (hovered ? '#ede7f6' : '#7b1fa208')
            : (hovered ? 'var(--hover-bg, #f5f5f5)' : 'transparent'),
          transition: 'background 0.1s', userSelect: 'none',
        }}
        title={`${table.tableName}: ${table.fields.join(', ')}`}
      >
        <span
          onClick={e => { e.stopPropagation(); if (resolvedFields.length > 0) setFieldsExpanded(prev => !prev); }}
          style={{ fontSize: 10, color: '#888', width: 12, textAlign: 'center', flexShrink: 0, cursor: resolvedFields.length > 0 ? 'pointer' : 'default' }}
        >
          {resolvedFields.length > 0 ? (fieldsExpanded ? '\u25BE' : '\u25B8') : '\u00A0\u00A0'}
        </span>
        <span style={{ fontSize: 14, flexShrink: 0 }}>{'\uD83D\uDCC1'}</span>
        <span style={{
          flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
          fontSize: 12, fontWeight: fds ? 600 : 400,
        }}>
          {tableLabel}
          {neutralizedCount > 0 && (
            <span style={{ fontSize: 9, color: '#7b1fa2', marginLeft: 4 }}>({neutralizedCount} {t('Felder')})</span>
          )}
        </span>

        <button
          onClick={e => { e.stopPropagation(); onSendToChat?.(_chatPayload); }}
          style={{
            background: 'none', border: 'none', cursor: 'pointer',
            fontSize: 13, padding: '0 2px', flexShrink: 0, lineHeight: 1, width: 22, textAlign: 'center',
            opacity: fds ? 0.7 : (hovered ? 0.5 : 0.25), color: '#7b1fa2',
          }}
          title={t('In Chat senden')}
        >
          {'\u{1F4AC}'}
        </button>
        <button
          onClick={async (e) => {
            e.stopPropagation();
            if (fds) { onCycleScope(fds); return; }
            const newId = await onAddFeatureTable(featureNode, table);
            if (newId) {
              try { await api.patch(`/api/datasources/${newId}/scope`, { scope: _nextScope(effectiveScope || 'personal') }); } catch {}
            }
          }}
          style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, padding: '0 2px', lineHeight: 1, flexShrink: 0, width: 22, textAlign: 'center', opacity: fds ? 1 : 0.35 }}
          title={fds ? `${t('Bereich')}: ${fds.scope}` : (effectiveScope ? `${t('Geerbt')}: ${effectiveScope}` : t('Scope setzen'))}
        >
          {_SCOPE_ICONS[fds?.scope || effectiveScope || 'personal']}
        </button>
        <button
          onClick={async (e) => {
            e.stopPropagation();
            if (fds) { onToggleNeutralize(fds); return; }
            const newId = await onAddFeatureTable(featureNode, table);
            if (newId) {
              try { await api.patch(`/api/datasources/${newId}/neutralize`, { neutralize: !effectiveNeutralize }); } catch {}
            }
          }}
          style={{
            background: 'none', border: 'none', cursor: 'pointer',
            fontSize: 12, padding: '0 2px', lineHeight: 1, flexShrink: 0, width: 22, textAlign: 'center',
            opacity: (fds?.neutralize ?? effectiveNeutralize) ? 1 : 0.35,
          }}
          title={(fds?.neutralize ?? effectiveNeutralize) ? t('Neutralisierung an') : t('Neutralisierung aus')}
        >
          {'\uD83D\uDD12'}
        </button>

      </div>

      {fieldsExpanded && resolvedFields.length > 0 && (
        <div>
          {resolvedFields.map(field => {
            const isNeutralized = (fds?.neutralizeFields || []).includes(field);
            return (
              <_FeatureFieldRow
                key={field}
                featureNode={featureNode}
                table={table}
                fieldName={field}
                depth={depth + 1}
                isNeutralized={isNeutralized || effectiveNeutralize}
                fds={fds}
                onToggleNeutralizeField={onToggleNeutralizeField}
                onSendToChat={onSendToChat}
                inheritedScope={fds?.scope ?? inheritedScope}
              />
            );
          })}
        </div>
      )}
    </div>
  );
};

/* ─── FeatureFieldRow (single field under a table) ────────────────────── */

interface _FeatureFieldRowProps {
  featureNode: FeatureConnectionNode;
  table: FeatureTableNode;
  fieldName: string;
  depth: number;
  isNeutralized: boolean;
  fds?: UdbFeatureDataSource;
  onToggleNeutralizeField?: (fds: UdbFeatureDataSource, fieldName: string) => void;
  onSendToChat?: (params: { featureInstanceId: string; featureCode: string; tableName?: string; objectKey: string; label: string; fieldName?: string }) => void;
  inheritedScope?: string;
}

const _FeatureFieldRow: React.FC<_FeatureFieldRowProps> = ({
  featureNode, table, fieldName, depth, isNeutralized, fds, onToggleNeutralizeField, onSendToChat, inheritedScope,
}) => {
  const { t } = useLanguage();
  const [hovered, setHovered] = useState(false);
  const _chatPayload = {
    featureInstanceId: featureNode.featureInstanceId,
    featureCode: featureNode.featureCode,
    tableName: table.tableName,
    objectKey: table.objectKey,
    label: `${table.label || table.tableName}.${fieldName}`,
    fieldName,
  };

  return (
    <div
      onMouseEnter={() => setHovered(true)}
      onMouseLeave={() => setHovered(false)}
      draggable
      onDragStart={(e) => {
        e.stopPropagation();
        e.dataTransfer.setData('application/feature-source', JSON.stringify(_chatPayload));
        e.dataTransfer.setData('text/plain', `${table.tableName}.${fieldName}`);
        e.dataTransfer.effectAllowed = 'copy';
      }}
      style={{
        display: 'flex', alignItems: 'center', gap: 4,
        paddingLeft: depth * 16 + 8, paddingRight: 4, paddingTop: 2, paddingBottom: 2,
        borderRadius: 3,
        background: isNeutralized
          ? (hovered ? '#f3e5f5' : '#f3e5f508')
          : (hovered ? 'var(--hover-bg, #f5f5f5)' : 'transparent'),
        transition: 'background 0.1s', userSelect: 'none',
        fontSize: 11,
      }}
    >
      <span style={{ fontSize: 10, color: '#999', flexShrink: 0 }}>{'\u2514'}</span>
      <span style={{
        flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
        fontWeight: isNeutralized ? 600 : 400,
        color: isNeutralized ? '#7b1fa2' : undefined,
      }}>
        {fieldName}
      </span>

      <button
        onClick={e => { e.stopPropagation(); onSendToChat?.(_chatPayload); }}
        style={{
          background: 'none', border: 'none', cursor: 'pointer',
          fontSize: 11, padding: '0 2px', flexShrink: 0, lineHeight: 1, width: 22, textAlign: 'center',
          opacity: fds ? 0.7 : (hovered ? 0.5 : 0.25), color: '#7b1fa2',
        }}
        title={t('Feld in Chat senden')}
      >
        {'\u{1F4AC}'}
      </button>
      <span
        style={{ fontSize: 11, padding: '0 2px', lineHeight: 1, flexShrink: 0, width: 22, textAlign: 'center', opacity: 0.25, display: 'inline-block' }}
        title={inheritedScope ? `${t('Geerbt')}: ${inheritedScope}` : t('Kein Scope gesetzt')}
      >
        {_SCOPE_ICONS[inheritedScope || 'personal']}
      </span>
      {fds && onToggleNeutralizeField ? (
        <button
          onClick={e => { e.stopPropagation(); onToggleNeutralizeField(fds, fieldName); }}
          style={{
            background: 'none', border: 'none', cursor: 'pointer',
            fontSize: 11, padding: '0 2px', lineHeight: 1, flexShrink: 0, width: 22, textAlign: 'center',
            opacity: isNeutralized ? 1 : 0.35,
          }}
          title={isNeutralized ? t('Feld-Neutralisierung an') : t('Feld-Neutralisierung aus')}
        >
          {'\uD83D\uDD12'}
        </button>
      ) : (
        <span
          style={{ fontSize: 11, padding: '0 2px', lineHeight: 1, flexShrink: 0, width: 22, textAlign: 'center', opacity: isNeutralized ? 0.5 : 0.15, display: 'inline-block' }}
          title={isNeutralized ? t('Neutralisierung geerbt') : t('Neutralisierung aus')}
        >
          {'\uD83D\uDD12'}
        </span>
      )}
    </div>
  );
};

export default SourcesTab;
