/**
 * Redmine Ticket Browser
 *
 * Split view: tree-as-table on the left (roots = configured root
 * tracker + virtual "Orphan" root), editor pane on the right. All reads
 * hit the local mirror; saves go through ``updateRedmineTicketApi``
 * which updates Redmine and then refreshes the mirror.
 *
 * Filters are applied client-side because the mirror already fits in
 * memory (2-20k tickets is fine for a sub-200ms filter pass).
 */

import React, {
  useCallback,
  useDeferredValue,
  useEffect,
  useMemo,
  useRef,
  useState,
} from 'react';

import { useApiRequest } from '../../../hooks/useApi';
import { useInstanceId } from '../../../hooks/useCurrentInstance';
import { useLanguage } from '../../../providers/language/LanguageContext';
import {
  RedmineConfigDto,
  RedmineFieldSchema,
  RedmineTicket,
  getRedmineConfigApi,
  getRedmineSchemaApi,
  listRedmineTicketsApi,
} from '../../../api/redmineApi';
import { PeriodPicker, PeriodValue } from '../../../components/PeriodPicker';

import {
  Forest,
  FlatRow,
  ORPHAN_ROOT_ID,
  buildForest,
  collectAllIds,
  flattenForest,
} from './redmineTreeLogic';
import { getTrackerStyle, sortByTrackerOrder } from './redmineTrackerColor';
import RedmineTicketEditor from './RedmineTicketEditor';

import styles from './RedmineViews.module.css';

// ============================================================================
// Relation type options -- Redmine's fixed vocabulary plus our synthetic
// "parent" edge (inherited from ``parent_id``).
// ============================================================================
const RELATION_TYPE_OPTIONS: Array<{ value: string; label: string }> = [
  { value: 'parent', label: 'parent_id' },
  { value: 'relates', label: 'relates' },
  { value: 'duplicates', label: 'duplicates' },
  { value: 'duplicated', label: 'duplicated' },
  { value: 'blocks', label: 'blocks' },
  { value: 'blocked', label: 'blocked' },
  { value: 'precedes', label: 'precedes' },
  { value: 'follows', label: 'follows' },
  { value: 'copied_to', label: 'copied_to' },
  { value: 'copied_from', label: 'copied_from' },
];

// ============================================================================
// Closed-state lookup -- the mirror's ``isClosed`` field can be stale or
// missing when the schema cache wasn't yet hydrated at sync time. We trust
// the live schema (``schema.statuses[*].isClosed``) as the source of truth
// and fall back to the ticket's own flag.
// ============================================================================
const _isTicketClosed = (
  ticket: RedmineTicket,
  schemaStatusClosedById: Map<number, boolean>,
): boolean => {
  if (ticket.statusId != null && schemaStatusClosedById.has(ticket.statusId)) {
    return schemaStatusClosedById.get(ticket.statusId) === true;
  }
  return !!ticket.isClosed;
};

export const RedmineBrowserView: React.FC = () => {
  const { t } = useLanguage();
  const { request } = useApiRequest();
  const instanceId = useInstanceId();

  const [config, setConfig] = useState<RedmineConfigDto | null>(null);
  const [schema, setSchema] = useState<RedmineFieldSchema | null>(null);
  const [tickets, setTickets] = useState<RedmineTicket[]>([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  // Filters
  const [period, setPeriod] = useState<PeriodValue | null>(null);
  // Tracker filter is *subtractive*: the set holds tracker ids that are
  // currently SHOWN. ``null`` means "uninitialised" -- once the schema is
  // loaded we seed the set with all tracker ids so every chip starts active.
  // Clicking a chip removes it from the set -> tickets of that tracker
  // disappear from the list.
  const [selectedTrackerIds, setSelectedTrackerIds] = useState<Set<number> | null>(null);
  const [selectedAssigneeIds, setSelectedAssigneeIds] = useState<Set<number>>(new Set());
  const [selectedRelTypes, setSelectedRelTypes] = useState<Set<string>>(
    new Set(RELATION_TYPE_OPTIONS.map(r => r.value)),
  );
  const [statusFilter, setStatusFilter] = useState<'*' | 'open' | 'closed'>('*');
  // Sprint = Redmine "fixed_version". Empty set => no filter; the synthetic
  // value ``__none__`` matches tickets with no sprint assigned.
  const [selectedSprints, setSelectedSprints] = useState<Set<string>>(new Set());

  // UI state
  const [expanded, setExpanded] = useState<Set<number>>(new Set());
  const [selectedId, setSelectedId] = useState<number | null>(null);

  const rootTrackerId = schema?.rootTrackerId ?? null;

  // Seed the tracker filter once the schema is available: every tracker
  // starts SELECTED so the user sees everything by default; clicking a chip
  // removes that tracker from the visible set.
  useEffect(() => {
    if (selectedTrackerIds == null && schema) {
      setSelectedTrackerIds(new Set(schema.trackers.map(tr => tr.id)));
    }
  }, [schema, selectedTrackerIds]);

  // Map statusId -> isClosed, taken from the live schema. Used by the status
  // filter so it works even if the mirror's per-ticket ``isClosed`` is stale.
  const schemaStatusClosedById = useMemo(() => {
    const m = new Map<number, boolean>();
    if (schema) {
      for (const s of schema.statuses) {
        if (typeof s.isClosed === 'boolean') m.set(s.id, s.isClosed);
      }
    }
    return m;
  }, [schema]);

  // Distinct sprints (fixed_version) seen across all loaded tickets. Drives
  // the sprint filter dropdown. Sorted alphabetically for stable UI.
  const sprintOptions = useMemo(() => {
    const m = new Map<string, string>();
    let hasNone = false;
    for (const tk of tickets) {
      const name = tk.fixedVersionName?.trim();
      if (name) {
        m.set(name, name);
      } else {
        hasNone = true;
      }
    }
    const opts = Array.from(m.entries())
      .map(([value, label]) => ({ value, label }))
      .sort((a, b) => a.label.localeCompare(b.label));
    if (hasNone) opts.unshift({ value: '__none__', label: t('(ohne Sprint)') });
    return opts;
  }, [tickets, t]);

  // Load config + schema once.
  const _loadMeta = useCallback(async () => {
    if (!instanceId) return;
    try {
      const [c, s] = await Promise.all([
        getRedmineConfigApi(request, instanceId),
        getRedmineSchemaApi(request, instanceId),
      ]);
      setConfig(c);
      setSchema(s);
    } catch (e: any) {
      setError(e?.response?.data?.detail || e?.message || t('Konfiguration laden fehlgeschlagen'));
    }
  }, [request, instanceId, t]);

  // Load tickets from mirror whenever the period window changes (backend can
  // pre-filter by updatedOn to shrink the payload).
  const _loadTickets = useCallback(async () => {
    if (!instanceId) return;
    setLoading(true);
    setError(null);
    try {
      const result = await listRedmineTicketsApi(request, instanceId, {
        status: '*',
        dateFrom: period?.fromDate,
        dateTo: period?.toDate,
      });
      setTickets(result);
    } catch (e: any) {
      setError(e?.response?.data?.detail || e?.message || t('Tickets laden fehlgeschlagen'));
      setTickets([]);
    } finally {
      setLoading(false);
    }
  }, [request, instanceId, period, t]);

  useEffect(() => { _loadMeta(); }, [_loadMeta]);
  useEffect(() => { _loadTickets(); }, [_loadTickets]);

  // Client-side filter pass (tracker / assignee / status). Root-tracker
  // tickets are always kept so the tree has roots even if their own tracker
  // is deselected -- otherwise the whole forest collapses.
  // ``deferredFilters`` lets React keep the filter chips snappy while the
  // potentially expensive tree rebuild happens in the background. The
  // chips update immediately (urgent state) but the tree picks up the new
  // values one tick later, which removes the "click feels frozen" lag.
  const deferredSelectedTrackerIds = useDeferredValue(selectedTrackerIds);
  const deferredSelectedAssigneeIds = useDeferredValue(selectedAssigneeIds);
  const deferredSelectedRelTypes = useDeferredValue(selectedRelTypes);
  const deferredStatusFilter = useDeferredValue(statusFilter);
  const deferredSelectedSprints = useDeferredValue(selectedSprints);

  const filteredTickets = useMemo(() => {
    const trackerSet = deferredSelectedTrackerIds;
    const assigneeSet = deferredSelectedAssigneeIds;
    const sprintSet = deferredSelectedSprints;
    const status = deferredStatusFilter;
    return tickets.filter(ticket => {
      const isRoot = rootTrackerId != null && ticket.trackerId === rootTrackerId;
      if (!isRoot && trackerSet != null && ticket.trackerId != null) {
        if (!trackerSet.has(ticket.trackerId)) return false;
      }
      if (assigneeSet.size > 0) {
        if (ticket.assignedToId == null || !assigneeSet.has(ticket.assignedToId)) return false;
      }
      if (status !== '*') {
        const closed = _isTicketClosed(ticket, schemaStatusClosedById);
        if (status === 'open' && closed) return false;
        if (status === 'closed' && !closed) return false;
      }
      if (sprintSet.size > 0) {
        const sprintKey = ticket.fixedVersionName?.trim() || '__none__';
        if (!sprintSet.has(sprintKey)) return false;
      }
      return true;
    });
  }, [tickets, rootTrackerId, deferredSelectedTrackerIds, deferredSelectedAssigneeIds, deferredStatusFilter, deferredSelectedSprints, schemaStatusClosedById]);

  // Convert the rel-type set to an array once per change instead of on every
  // ``buildForest`` call (Array.from() in the deps would re-run the memo
  // every render because the array identity is fresh each time).
  const allowedRelTypesArr = useMemo(
    () => Array.from(deferredSelectedRelTypes),
    [deferredSelectedRelTypes],
  );

  const forest: Forest = useMemo(() => {
    return buildForest(filteredTickets, {
      rootTrackerId,
      allowedRelTypes: allowedRelTypesArr,
    });
  }, [filteredTickets, rootTrackerId, allowedRelTypesArr]);

  const flatRows: FlatRow[] = useMemo(
    () => flattenForest(forest.trees, expanded),
    [forest.trees, expanded],
  );

  const ticketsById = useMemo(() => {
    const m = new Map<number, RedmineTicket>();
    for (const ticket of tickets) m.set(ticket.id, ticket);
    return m;
  }, [tickets]);

  // One-shot initial expansion: the very first time we render a non-empty
  // forest, expand the root nodes so the user sees the overview. After that
  // the user owns the expand state -- collapsing all must STAY collapsed.
  const _didInitExpand = useRef(false);
  useEffect(() => {
    if (!_didInitExpand.current && forest.trees.length > 0) {
      _didInitExpand.current = true;
      setExpanded(new Set(forest.trees.map(tr => tr.id)));
    }
  }, [forest.trees]);

  const _toggleExpand = useCallback((id: number) => {
    setExpanded(prev => {
      const next = new Set(prev);
      if (next.has(id)) next.delete(id); else next.add(id);
      return next;
    });
  }, []);

  const _expandAll = useCallback(() => {
    setExpanded(new Set(collectAllIds(forest.trees)));
  }, [forest.trees]);

  const _collapseAll = useCallback(() => {
    setExpanded(new Set());
  }, []);

  const _resetFilters = useCallback(() => {
    setPeriod(null);
    // "Alle Tracker sichtbar" entspricht dem initialen, voll bestueckten Set.
    setSelectedTrackerIds(schema ? new Set(schema.trackers.map(tr => tr.id)) : null);
    setSelectedAssigneeIds(new Set());
    setSelectedRelTypes(new Set(RELATION_TYPE_OPTIONS.map(r => r.value)));
    setStatusFilter('*');
    setSelectedSprints(new Set());
  }, [schema]);

  const _toggleSprint = useCallback((value: string) => {
    setSelectedSprints(prev => {
      const next = new Set(prev);
      if (next.has(value)) next.delete(value); else next.add(value);
      return next;
    });
  }, []);

  const _toggleTracker = useCallback((id: number) => {
    setSelectedTrackerIds(prev => {
      // ``prev`` is null only before schema loads -- the chip wouldn't be
      // clickable in that state, but stay defensive.
      const next = new Set(prev ?? []);
      if (next.has(id)) next.delete(id); else next.add(id);
      return next;
    });
  }, []);

  const _toggleRelType = useCallback((rt: string) => {
    setSelectedRelTypes(prev => {
      const next = new Set(prev);
      if (next.has(rt)) next.delete(rt); else next.add(rt);
      return next;
    });
  }, []);

  const _handleTicketSaved = useCallback((updated: RedmineTicket) => {
    setTickets(prev => prev.map(x => (x.id === updated.id ? updated : x)));
  }, []);

  if (!instanceId) {
    return <div className={styles.placeholder}>{t('Keine Feature-Instanz ausgewaehlt')}</div>;
  }

  return (
    <div className={styles.browserPage}>
      <div className={styles.browserHeader}>
        <div>
          <h2 className={styles.heading} style={{ margin: 0 }}>{t('Redmine -- Ticket-Browser')}</h2>
          <p className={styles.subheading} style={{ margin: 0 }}>
            {t('Baum aus dem lokalen Mirror. Roots: {name}. Tickets ohne Verbindung landen unter "Orphan User Story".', {
              name: schema?.rootTrackerName || config?.rootTrackerName || '—',
            })}
          </p>
        </div>
        <div style={{ fontSize: '0.8rem', color: 'var(--text-secondary, #718096)' }}>
          {t('{count} von {total} Tickets sichtbar', {
            count: filteredTickets.length,
            total: tickets.length,
          })}
        </div>
      </div>

      {error && <div className={styles.alertErr}>{error}</div>}

      <div className={styles.browserFilters}>
        <div className={styles.filterGroup}>
          <label>{t('Zeitraum (letzte Aenderung)')}</label>
          <PeriodPicker
            value={period}
            onChange={(next) => setPeriod(next)}
            direction="past"
            defaultPreset={{ kind: 'lastQuarter' }}
            enabledPresets={[
              'thisMonth', 'lastMonth', 'thisQuarter', 'lastQuarter',
              'ytd', 'lastYear', 'last12Months', 'lastN', 'custom',
            ]}
            placeholder={t('Alle Zeiten')}
          />
        </div>

        <div className={styles.filterGroup}>
          <label>Status</label>
          <select
            className={styles.select}
            value={statusFilter}
            onChange={e => setStatusFilter(e.target.value as any)}
          >
            <option value="*">{t('Alle')}</option>
            <option value="open">{t('Nur offen')}</option>
            <option value="closed">{t('Nur geschlossen')}</option>
          </select>
        </div>

        {schema && schema.trackers.length > 0 && (
          <div className={styles.filterGroup} style={{ minWidth: 240 }}>
            <label>{t('Tracker (Klick blendet aus -- Root bleibt immer aktiv)')}</label>
            <div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
              {sortByTrackerOrder(schema.trackers, tr => tr.name).map(tr => {
                const isRoot = tr.id === rootTrackerId;
                // Active iff in the visible set (or root). selectedTrackerIds
                // is null only during the brief window before the schema seed
                // effect runs -- treat as "all visible".
                const active = isRoot || selectedTrackerIds == null || selectedTrackerIds.has(tr.id);
                const sty = getTrackerStyle(tr.name);
                return (
                  <button
                    key={tr.id}
                    type="button"
                    onClick={() => !isRoot && _toggleTracker(tr.id)}
                    style={{
                      padding: '2px 9px',
                      borderRadius: 999,
                      border: `1px solid ${active ? sty.border : 'var(--border-color, #e2e8f0)'}`,
                      background: active ? sty.bg : '#fff',
                      color: active ? sty.fg : 'var(--text-secondary, #4a5568)',
                      fontSize: '0.75rem',
                      cursor: isRoot ? 'default' : 'pointer',
                      fontFamily: 'inherit',
                      opacity: isRoot ? 0.9 : 1,
                      textDecoration: !active ? 'line-through' : 'none',
                    }}
                    title={
                      isRoot
                        ? t('Root-Tracker -- immer aktiv')
                        : active
                          ? t('Klicken, um {name} auszublenden', { name: tr.name })
                          : t('Klicken, um {name} wieder anzuzeigen', { name: tr.name })
                    }
                  >
                    {isRoot ? `⚓ ${tr.name}` : tr.name}
                  </button>
                );
              })}
            </div>
          </div>
        )}

        {schema && schema.users.length > 0 && (
          <div className={styles.filterGroup} style={{ minWidth: 220 }}>
            <label>{t('Zuweisung')}</label>
            <select
              className={styles.select}
              multiple
              size={Math.min(4, schema.users.length)}
              value={Array.from(selectedAssigneeIds).map(String)}
              onChange={e => {
                const ids = Array.from(e.target.selectedOptions).map(o => Number(o.value));
                setSelectedAssigneeIds(new Set(ids));
              }}
              style={{ height: 'auto' }}
            >
              {schema.users.map(u => (
                <option key={u.id} value={u.id}>{u.name}</option>
              ))}
            </select>
          </div>
        )}

        {sprintOptions.length > 0 && (
          <div className={styles.filterGroup} style={{ minWidth: 220 }}>
            <label>{t('Sprint (Zielversion)')}</label>
            <div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, maxHeight: 88, overflow: 'auto' }}>
              {sprintOptions.map(sp => {
                const active = selectedSprints.has(sp.value);
                return (
                  <button
                    key={sp.value}
                    type="button"
                    onClick={() => _toggleSprint(sp.value)}
                    style={{
                      padding: '2px 8px',
                      borderRadius: 4,
                      border: `1px solid ${active ? '#4A6FA5' : 'var(--border-color, #e2e8f0)'}`,
                      background: active ? '#4A6FA5' : '#fff',
                      color: active ? '#fff' : 'var(--text-secondary, #4a5568)',
                      fontSize: '0.72rem',
                      cursor: 'pointer',
                      fontFamily: 'inherit',
                    }}
                    title={sp.label}
                  >
                    {sp.label}
                  </button>
                );
              })}
            </div>
            {selectedSprints.size === 0 && (
              <span style={{ fontSize: '0.7rem', color: 'var(--text-secondary, #718096)' }}>
                {t('keine Auswahl = alle Sprints')}
              </span>
            )}
          </div>
        )}

        <div className={styles.filterGroup} style={{ minWidth: 260 }}>
          <label>{t('Beziehungsarten')}</label>
          <div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
            {RELATION_TYPE_OPTIONS.map(rt => {
              const active = selectedRelTypes.has(rt.value);
              return (
                <button
                  key={rt.value}
                  type="button"
                  onClick={() => _toggleRelType(rt.value)}
                  title={t('Beziehungstyp ein-/ausschalten -- nur aktive Typen werden im Baum verfolgt')}
                  style={{
                    padding: '2px 8px',
                    borderRadius: 4,
                    border: `1px solid ${active ? '#4A6FA5' : 'var(--border-color, #e2e8f0)'}`,
                    background: active ? '#4A6FA5' : '#fff',
                    color: active ? '#fff' : 'var(--text-secondary, #4a5568)',
                    fontSize: '0.72rem',
                    cursor: 'pointer',
                    fontFamily: 'inherit',
                  }}
                >
                  {rt.label}
                </button>
              );
            })}
          </div>
        </div>

        <div style={{ flex: 1 }} />

        <button className={styles.btnSecondary} onClick={_resetFilters}>
          {t('Filter zuruecksetzen')}
        </button>
      </div>

      <div className={styles.browserBody}>
        {/* Left: tree */}
        <div className={styles.browserTreeContainer}>
          <div className={styles.browserToolbar}>
            <span>
              {t('{rows} Zeilen sichtbar -- {orphans} Orphan-Tickets', {
                rows: flatRows.length,
                orphans: forest.orphanCount,
              })}
            </span>
            <div style={{ display: 'flex', gap: '0.25rem' }}>
              <button className={styles.linkBtn} onClick={_expandAll} disabled={flatRows.length === 0}>
                {t('Alle ausklappen')}
              </button>
              <button className={styles.linkBtn} onClick={_collapseAll} disabled={flatRows.length === 0}>
                {t('Einklappen')}
              </button>
            </div>
          </div>
          <div className={styles.treeScroll}>
            {loading ? (
              <div className={styles.loading}>{t('Baum wird aufgebaut ...')}</div>
            ) : flatRows.length === 0 ? (
              <div className={styles.placeholder}>
                {t('Keine Tickets sichtbar. Pruefe Filter oder fuehre einen Sync auf der Einstellungen-Seite aus.')}
              </div>
            ) : (
              <div className={styles.treeGrid}>
                <div className={styles.treeHeader}>
                  <div>{t('Ticket')}</div>
                  <div>Status</div>
                  <div>{t('Prio')}</div>
                  <div>{t('Zuweisung')}</div>
                  <div>{t('Geaendert')}</div>
                  <div>{t('Beziehung')}</div>
                </div>
                {flatRows.map(row => (
                  <TreeRow
                    key={row.node.id}
                    row={row}
                    ticket={ticketsById.get(row.node.id) || null}
                    selected={selectedId === row.node.id}
                    expanded={expanded.has(row.node.id)}
                    onToggle={_toggleExpand}
                    onSelect={setSelectedId}
                    rootTrackerId={rootTrackerId}
                    schemaStatusClosedById={schemaStatusClosedById}
                  />
                ))}
              </div>
            )}
          </div>
        </div>

        {/* Right: editor */}
        <div className={styles.browserEditorContainer}>
          {selectedId == null ? (
            <div className={styles.browserToolbar}>{t('Ticket links auswaehlen')}</div>
          ) : selectedId === ORPHAN_ROOT_ID ? (
            <div className={styles.browserToolbar}>
              {t('Virtueller "Orphan User Story"-Knoten -- enthaelt {count} Tickets ohne Verbindung.', {
                count: forest.orphanCount,
              })}
            </div>
          ) : (
            <RedmineTicketEditor
              instanceId={instanceId}
              ticketId={selectedId}
              schema={schema}
              baseUrl={config?.baseUrl || ''}
              onSaved={_handleTicketSaved}
            />
          )}
        </div>
      </div>
    </div>
  );
};

// ============================================================================
// TreeRow -- a single grid row, handling its own indent painting.
// ============================================================================

interface TreeRowProps {
  row: FlatRow;
  ticket: RedmineTicket | null;
  selected: boolean;
  expanded: boolean;
  onToggle: (id: number) => void;
  onSelect: (id: number) => void;
  rootTrackerId: number | null;
  schemaStatusClosedById: Map<number, boolean>;
}

const _TreeRowImpl: React.FC<TreeRowProps> = ({
  row, ticket, selected, expanded, onToggle, onSelect, rootTrackerId, schemaStatusClosedById,
}) => {
  const { node, depth, indentLines, hasChildren } = row;
  const isOrphanRoot = node.id === ORPHAN_ROOT_ID;
  const isRootTracker = !isOrphanRoot && ticket?.trackerId === rootTrackerId;
  const isClosed = ticket ? _isTicketClosed(ticket, schemaStatusClosedById) : false;

  const rowClass = [
    styles.treeRow,
    selected ? styles.selected : '',
    isOrphanRoot ? styles.orphan : '',
  ].filter(Boolean).join(' ');

  const _handleToggle = (e: React.MouseEvent) => {
    e.stopPropagation();
    onToggle(node.id);
  };

  const _handleSelect = () => onSelect(node.id);

  return (
    <div className={rowClass} onClick={_handleSelect}>
      <div className={styles.treeCell}>
        {Array.from({ length: Math.max(0, depth - 1) }).map((_, i) => (
          <span
            key={i}
            className={`${styles.indent}${indentLines[i] ? ` ${styles.line ?? 'line'}` : ''}`}
          />
        ))}
        {depth > 0 && (
          <span className={`${styles.indentElbow}${row.isLast ? '' : ` ${styles.mid ?? 'mid'}`}`}>
            <span className={styles.elbowBar} />
          </span>
        )}
        {hasChildren ? (
          <button className={styles.treeToggle} onClick={_handleToggle} title={expanded ? 'Einklappen' : 'Ausklappen'}>
            {expanded ? '▾' : '▸'}
          </button>
        ) : (
          <span className={styles.treeTogglePlaceholder} />
        )}
        <span
          className={`${styles.trackerPill} ${isOrphanRoot ? styles.orphanPill : ''}`}
          style={
            !isOrphanRoot && ticket?.trackerName
              ? (() => {
                  const sty = getTrackerStyle(ticket.trackerName);
                  return {
                    background: sty.bg,
                    color: sty.fg,
                    border: `1px solid ${sty.border}`,
                  };
                })()
              : undefined
          }
        >
          {isOrphanRoot ? 'Orphan' : (ticket?.trackerName || '—')}
        </span>
        <span className={styles.ticketId}>
          {isOrphanRoot ? '—' : `#${ticket?.id}`}
        </span>
        <span className={styles.ticketSubject} title={isOrphanRoot ? '' : (ticket?.subject || '')}>
          {isOrphanRoot
            ? `Tickets ohne Verbindung zu einer User Story (${node.children.length})`
            : (ticket?.subject || '(ohne Titel)')}
        </span>
      </div>
      <div>
        {isOrphanRoot ? <span className={styles.muted}>—</span>
          : ticket?.statusName
            ? <span className={`${styles.statusPill}${isClosed ? ` ${styles.closed ?? 'closed'}` : ''}`}>{ticket.statusName}</span>
            : <span className={styles.muted}>—</span>}
      </div>
      <div>{isOrphanRoot ? <span className={styles.muted}>—</span> : (ticket?.priorityName || '—')}</div>
      <div>{isOrphanRoot ? <span className={styles.muted}>—</span> : (ticket?.assignedToName || <span className={styles.muted}>—</span>)}</div>
      <div style={{ fontVariantNumeric: 'tabular-nums' }}>
        {isOrphanRoot ? '' : (ticket?.updatedOn ? ticket.updatedOn.slice(0, 10) : '')}
      </div>
      <div>
        {isOrphanRoot
          ? <span className={styles.relBadge}>virtuell</span>
          : isRootTracker && depth === 0
            ? <span className={`${styles.relBadge} ${styles.root ?? 'root'}`}>Root</span>
            : node.relType
              ? <span className={styles.relBadge}>{node.dir === 'in' ? '←' : '→'} {node.relType}</span>
              : <span className={styles.muted}>—</span>}
      </div>
    </div>
  );
};

// Custom equality: skip re-rendering rows whose visible inputs are identical.
// ``row`` and ``ticket`` references are stable across re-renders unless the
// underlying tree was rebuilt or the ticket itself changed -- much cheaper
// than re-painting 2k DOM nodes on every filter chip click.
const TreeRow = React.memo(_TreeRowImpl, (prev, next) => {
  return (
    prev.row === next.row
    && prev.ticket === next.ticket
    && prev.selected === next.selected
    && prev.expanded === next.expanded
    && prev.onToggle === next.onToggle
    && prev.onSelect === next.onSelect
    && prev.rootTrackerId === next.rootTrackerId
    && prev.schemaStatusClosedById === next.schemaStatusClosedById
  );
});

export default RedmineBrowserView;
