/**
 * Editor slice — manages project editor state.
 *
 * Group 1: File tree state + actions (iter-1)
 * Group 2: Tab management (iter-2)
 * Group 3: Dirty/save state (iter-2)
 * Group 4: File operations (iter-3)
 */

import { api } from '@renderer/api';
import { getLanguageFromFileName } from '@renderer/utils/codemirrorLanguages';
import { editorBridge } from '@renderer/utils/editorBridge';
import { invalidateQuickOpenCache } from '@renderer/utils/quickOpenCache';
import { computeDisambiguatedTabs } from '@renderer/utils/tabLabelDisambiguation';
import { createLogger } from '@shared/utils/logger';
import {
  getBasename,
  isPathPrefix,
  isWindowsishPath,
  joinPath,
  lastSeparatorIndex,
  splitPath,
  stripTrailingSeparators,
} from '@shared/utils/platformPath';

import type { AppState } from '../types';
import type {
  EditorFileChangeEvent,
  EditorFileTab,
  FileTreeEntry,
  GitFileStatus,
} from '@shared/types/editor';
import type { StateCreator } from 'zustand';

const log = createLogger('Store:editor');

/** Remove a key from a record. Returns the same reference if key doesn't exist. */
function omitKey<V>(record: Record<string, V>, key: string): Record<string, V> {
  if (!(key in record)) return record;
  const result = { ...record };
  delete result[key];
  return result;
}

/**
 * Cooldown map: filePath → timestamp of last successful save.
 *
 * Used to suppress watcher events that arrive after editorSaving is cleared
 * (race condition: atomic write → IPC response → clear saving flag → watcher fires).
 * macOS FSEvents can delay up to ~1s; 2s cooldown covers all platforms safely.
 *
 * Module-level (not in store state) to avoid unnecessary re-renders.
 */
const recentSaveTimestamps = new Map<string, number>();
const SAVE_COOLDOWN_MS = 2000;

/**
 * Throttle timers for watcher-driven updates.
 * Keeping these module-level avoids store re-renders during bursts.
 */
let gitStatusThrottleTimer: ReturnType<typeof setTimeout> | null = null;
const GIT_STATUS_THROTTLE_MS = 1500;
const dirRefreshDebounceTimers = new Map<string, ReturnType<typeof setTimeout>>();
const DIR_REFRESH_DEBOUNCE_MS = 350;

// Watcher event logging can be extremely expensive during bursts.
// Keep a lightweight aggregate counter instead of logging per event.
let watcherEventLogTimer: ReturnType<typeof setTimeout> | null = null;
let watcherEventCounts: Record<EditorFileChangeEvent['type'], number> = {
  change: 0,
  create: 0,
  delete: 0,
};

let watchedFilesSyncTimer: ReturnType<typeof setTimeout> | null = null;
let lastWatchedFilesKey = '';
let watchedDirsSyncTimer: ReturnType<typeof setTimeout> | null = null;
let lastWatchedDirsKey = '';
const WATCHED_DIRS_DEBOUNCE_MS = 250;
const MAX_WATCHED_DIRS = 120;

function scheduleSyncWatchedFiles(get: () => AppState): void {
  const state = get();
  if (!state.editorWatcherEnabled) return;
  const projectPath = state.editorProjectPath;
  if (!projectPath) return;

  const filePaths = state.editorOpenTabs.map((t) => t.filePath).filter(Boolean);
  filePaths.sort((a, b) => a.localeCompare(b));
  const key = `${projectPath}\n${filePaths.join('\n')}`;
  if (key === lastWatchedFilesKey) return;
  lastWatchedFilesKey = key;

  if (watchedFilesSyncTimer) clearTimeout(watchedFilesSyncTimer);
  watchedFilesSyncTimer = setTimeout(() => {
    watchedFilesSyncTimer = null;
    void api.editor.setWatchedFiles(filePaths);
  }, 150);
}

function scheduleSyncWatchedDirs(get: () => AppState): void {
  const state = get();
  if (!state.editorWatcherEnabled) return;
  const projectPath = state.editorProjectPath;
  if (!projectPath) return;

  const expanded = Object.entries(state.editorExpandedDirs)
    .filter(([, v]) => v === true)
    .map(([k]) => k);

  // Always include root (depth=0), plus expanded folders (depth=0).
  // Cap to protect chokidar from too many watched paths if user expands a lot.
  const dirs = [projectPath, ...expanded].slice(0, MAX_WATCHED_DIRS);
  dirs.sort((a, b) => a.localeCompare(b));
  const key = `${projectPath}\n${dirs.join('\n')}`;
  if (key === lastWatchedDirsKey) return;
  lastWatchedDirsKey = key;

  if (watchedDirsSyncTimer) clearTimeout(watchedDirsSyncTimer);
  watchedDirsSyncTimer = setTimeout(() => {
    watchedDirsSyncTimer = null;
    void api.editor.setWatchedDirs(dirs);
  }, WATCHED_DIRS_DEBOUNCE_MS);
}

/**
 * Open request sequence for editor initialization.
 * Cancels stale async work (notably React 18 StrictMode dev effect mount/unmount).
 */
let editorOpenSeq = 0;

/**
 * Cooldown map: filePath → timestamp of last successful move.
 * Suppresses watcher events triggered by our own move operations.
 */
const recentMoveTimestamps = new Map<string, number>();
const MOVE_COOLDOWN_MS = 2000;

function scheduleIdleWork(cb: () => void): void {
  // Prefer requestIdleCallback when available; fall back to a short timeout.
  // This keeps editor open responsive for large repos.
  // timeout ensures the callback fires within 2s even if the event loop is busy
  // (without it, requestIdleCallback can be delayed indefinitely).
  try {
    const ric = (
      window as unknown as {
        requestIdleCallback?: (fn: () => void, opts?: { timeout: number }) => number;
      }
    ).requestIdleCallback;
    if (typeof ric === 'function') {
      ric(cb, { timeout: 2000 });
      return;
    }
  } catch {
    // ignore
  }
  setTimeout(cb, 150);
}

// =============================================================================
// Slice Interface
// =============================================================================

export interface EditorSlice {
  // ═══════════════════════════════════════════════════════
  // Group 1: File tree state + actions
  // ═══════════════════════════════════════════════════════
  editorProjectPath: string | null;
  editorFileTree: FileTreeEntry[] | null;
  editorFileTreeLoading: boolean;
  editorFileTreeError: string | null;
  editorExpandedDirs: Record<string, boolean>;

  openEditor: (projectPath: string) => Promise<void>;
  closeEditor: () => void;
  loadFileTree: (dirPath: string) => Promise<void>;
  expandDirectory: (dirPath: string) => Promise<void>;
  collapseDirectory: (dirPath: string) => void;

  // ═══════════════════════════════════════════════════════
  // Group 2: Tab management
  // ═══════════════════════════════════════════════════════
  editorOpenTabs: EditorFileTab[];
  editorActiveTabId: string | null;

  openFile: (filePath: string) => void;
  closeEditorTab: (tabId: string) => void;
  closeOtherEditorTabs: (keepTabId: string) => void;
  closeEditorTabsToLeft: (tabId: string) => void;
  closeEditorTabsToRight: (tabId: string) => void;
  closeAllEditorTabs: () => void;
  setActiveEditorTab: (tabId: string) => void;
  reorderEditorTabs: (activeId: string, overId: string) => void;

  // ═══════════════════════════════════════════════════════
  // Group 3: Content + Save
  // Content lives in EditorState (Map<tabId, EditorState> in useRef).
  // Store only tracks dirty flags, loading, and save status.
  // ═══════════════════════════════════════════════════════
  editorFileLoading: Record<string, boolean>;
  editorModifiedFiles: Record<string, boolean>;
  editorSaving: Record<string, boolean>;
  editorSaveError: Record<string, string>;

  markFileModified: (filePath: string) => void;
  markFileSaved: (filePath: string) => void;
  saveFile: (filePath: string) => Promise<void>;
  saveAllFiles: () => Promise<void>;
  discardChanges: (filePath: string) => void;
  hasUnsavedChanges: () => boolean;

  // ═══════════════════════════════════════════════════════
  // Group 4: File operations (iter-3)
  // ═══════════════════════════════════════════════════════
  editorCreating: boolean;
  editorCreateError: string | null;

  createFileInTree: (parentDir: string, fileName: string) => Promise<string | null>;
  createDirInTree: (parentDir: string, dirName: string) => Promise<string | null>;
  deleteFileFromTree: (filePath: string) => Promise<boolean>;
  moveFileInTree: (sourcePath: string, destDir: string) => Promise<boolean>;
  renameFileInTree: (sourcePath: string, newName: string) => Promise<boolean>;

  // ═══════════════════════════════════════════════════════
  // Group 5: Git status + file watcher + line wrap (iter-5)
  // ═══════════════════════════════════════════════════════
  editorGitFiles: GitFileStatus[];
  editorGitBranch: string | null;
  editorIsGitRepo: boolean;
  editorGitLoading: boolean;
  editorWatcherEnabled: boolean;
  editorLineWrap: boolean;
  /** Files changed on disk while open (absolute paths) */
  editorExternalChanges: Record<string, EditorFileChangeEvent['type']>;
  /** Baseline mtime per file (for conflict detection) */
  editorFileMtimes: Record<string, number>;
  /** File path with active save conflict (null = no conflict) */
  editorConflictFile: string | null;

  /** Pending line to scroll to after file loads (1-based). Set by search result click. */
  editorPendingGoToLine: number | null;
  setPendingGoToLine: (line: number | null) => void;

  /** File path to reveal in editor (opens editor, expands dirs, opens tab, focuses in tree). */
  editorPendingRevealFile: string | null;
  /** Request to reveal a file in the editor. Opens editor overlay if needed. */
  revealFileInEditor: (filePath: string) => void;
  /** Request to reveal a folder in the editor tree. Expands parent dirs + the folder itself. */
  revealFolderInEditor: (folderPath: string) => void;
  /** Process the pending reveal: expand parent dirs and open the file tab. */
  revealAndOpenFile: (filePath: string) => Promise<void>;
  clearPendingRevealFile: () => void;

  fetchGitStatus: () => Promise<void>;
  toggleWatcher: (enable: boolean) => Promise<void>;
  toggleLineWrap: () => void;
  handleExternalFileChange: (event: EditorFileChangeEvent) => void;
  clearExternalChange: (filePath: string) => void;
  setFileMtime: (filePath: string, mtimeMs: number) => void;
  forceOverwrite: (filePath: string) => Promise<void>;
  resolveConflict: () => void;
}

// =============================================================================
// Slice Creator
// =============================================================================

export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (set, get) => ({
  // Group 1 initial state
  editorProjectPath: null,
  editorFileTree: null,
  editorFileTreeLoading: false,
  editorFileTreeError: null,
  editorExpandedDirs: {},

  // Group 2 initial state
  editorOpenTabs: [],
  editorActiveTabId: null,

  // Group 3 initial state
  editorFileLoading: {},
  editorModifiedFiles: {},
  editorSaving: {},
  editorSaveError: {},

  // Group 4 initial state
  editorCreating: false,
  editorCreateError: null,

  // Group 5 initial state
  editorGitFiles: [],
  editorGitBranch: null,
  editorIsGitRepo: false,
  editorGitLoading: false,
  editorWatcherEnabled: false,
  editorLineWrap: (() => {
    try {
      return localStorage.getItem('editor-line-wrap') === 'true';
    } catch {
      return false;
    }
  })(),
  editorExternalChanges: {},
  editorFileMtimes: {},
  editorConflictFile: null,
  editorPendingGoToLine: null,
  editorPendingRevealFile: null,

  setPendingGoToLine: (line: number | null) => set({ editorPendingGoToLine: line }),

  revealFileInEditor: (filePath: string) => {
    set({ editorPendingRevealFile: filePath });
  },

  revealFolderInEditor: (folderPath: string) => {
    // Set pending reveal so EditorFileTree scrolls to the folder
    set({ editorPendingRevealFile: folderPath });

    // Expand parent dirs + the folder itself
    const { editorProjectPath, editorFileTree, expandDirectory } = get();
    if (!editorProjectPath || !editorFileTree) return;

    const root = stripTrailingSeparators(editorProjectPath);
    const rootParts = splitPath(root);
    const folderParts = splitPath(folderPath);
    const win = isWindowsishPath(root);
    const eq = (a: string, b: string): boolean =>
      win ? a.toLowerCase() === b.toLowerCase() : a === b;
    const hasPrefix =
      folderParts.length >= rootParts.length &&
      rootParts.every((seg, i) => eq(seg, folderParts[i]));

    if (hasPrefix) {
      const segments = folderParts.slice(rootParts.length);
      let currentDir = root;
      // Expand each segment including the folder itself
      const doExpand = async (): Promise<void> => {
        for (const seg of segments) {
          currentDir = joinPath(currentDir, seg);
          await expandDirectory(currentDir);
        }
        set({ editorPendingRevealFile: null });
      };
      void doExpand();
    }
  },

  clearPendingRevealFile: () => {
    set({ editorPendingRevealFile: null });
  },

  revealAndOpenFile: async (filePath: string) => {
    const { editorProjectPath, editorFileTree, expandDirectory, openFile } = get();
    if (!editorProjectPath) return;

    // Guard: file tree must be loaded before we can reveal.
    // If it's still null, bail out WITHOUT clearing pendingRevealFile
    // so the caller effect can retry after the tree loads.
    if (!editorFileTree) {
      log.info('revealAndOpenFile: tree not loaded yet, deferring reveal');
      return;
    }

    // Compute parent directories from projectRoot to the file.
    // Must handle both `/` and `\` because paths may arrive from any OS.
    const root = stripTrailingSeparators(editorProjectPath);
    const rootParts = splitPath(root);
    const fileParts = splitPath(filePath);
    const win = isWindowsishPath(root);
    const eq = (a: string, b: string): boolean =>
      win ? a.toLowerCase() === b.toLowerCase() : a === b;
    const hasPrefix =
      fileParts.length >= rootParts.length && rootParts.every((seg, i) => eq(seg, fileParts[i]));

    if (hasPrefix) {
      const segments = fileParts.slice(rootParts.length);
      // Expand each parent directory sequentially (root → child → grandchild).
      // Skip the last segment (the file name itself).
      // Each expandDirectory call is awaited so that its children are merged
      // into the tree before the next level is expanded.
      let currentDir = root;
      for (let i = 0; i < segments.length - 1; i++) {
        currentDir = joinPath(currentDir, segments[i] ?? '');
        await expandDirectory(currentDir);
      }
    }

    // Open the file as a tab
    openFile(filePath);
    // Clear reveal state
    set({ editorPendingRevealFile: null });
  },

  // ═══════════════════════════════════════════════════════
  // Group 1: File tree actions
  // ═══════════════════════════════════════════════════════

  openEditor: async (projectPath: string) => {
    const openSeq = ++editorOpenSeq;
    set({
      editorProjectPath: projectPath,
      editorFileTree: null,
      editorFileTreeLoading: true,
      editorFileTreeError: null,
      editorExpandedDirs: {},
      editorOpenTabs: [],
      editorActiveTabId: null,
      editorFileLoading: {},
      editorModifiedFiles: {},
      editorSaving: {},
      editorSaveError: {},
      editorCreating: false,
      editorCreateError: null,
      editorGitFiles: [],
      editorGitBranch: null,
      editorIsGitRepo: false,
      editorGitLoading: false,
      editorWatcherEnabled: false,
      editorExternalChanges: {},
      editorFileMtimes: {},
      editorConflictFile: null,
      editorPendingGoToLine: null,
    });

    try {
      const tOpen = performance.now();
      await api.editor.open(projectPath);
      const openMs = performance.now() - tOpen;

      // Cancel stale opens (e.g. StrictMode effect cleanup, or rapid project switching)
      if (editorOpenSeq !== openSeq || get().editorProjectPath !== projectPath) {
        return;
      }

      // Load file tree first so UI becomes interactive quickly.
      // Git status and file watching can be expensive on large projects, so they are NOT awaited here.
      const tReadDir = performance.now();
      const result = await api.editor.readDir(projectPath);
      const readDirMs = performance.now() - tReadDir;

      if (editorOpenSeq !== openSeq || get().editorProjectPath !== projectPath) {
        return;
      }

      const tSet = performance.now();
      set({
        editorFileTree: result.entries,
        editorFileTreeLoading: false,
      });
      const setMs = performance.now() - tSet;

      log.info(
        `[perf] openEditor: open=${openMs.toFixed(1)}ms, readDir=${readDirMs.toFixed(1)}ms, set=${setMs.toFixed(1)}ms, entries=${result.entries.length}`
      );

      // Enable watcher by default (like most editors), but defer startup until idle so open stays fast.
      // Allow users to persistently disable it via localStorage toggle.
      const watcherDesired = (() => {
        try {
          return localStorage.getItem('editor-watcher-enabled') !== 'false';
        } catch {
          return true;
        }
      })();

      scheduleIdleWork(() => {
        if (editorOpenSeq !== openSeq || get().editorProjectPath !== projectPath) return;
        if (watcherDesired) void get().toggleWatcher(true);
        // Defer initial git status a bit more — it can be expensive on large repos.
        setTimeout(() => {
          if (editorOpenSeq !== openSeq || get().editorProjectPath !== projectPath) return;
          void get().fetchGitStatus();
        }, 1200);
      });
    } catch (error) {
      // Ignore errors from stale opens (e.g. StrictMode cleanup during dev)
      if (editorOpenSeq !== openSeq || get().editorProjectPath !== projectPath) {
        return;
      }
      const message = error instanceof Error ? error.message : String(error);
      log.error('Failed to open editor:', message);
      set({
        editorFileTreeLoading: false,
        editorFileTreeError: message,
      });
    }
  },

  closeEditor: () => {
    // Cancel any in-flight openEditor async work
    editorOpenSeq++;
    // Cancel any pending watcher sync (avoid calling into main after close)
    if (watchedFilesSyncTimer) {
      clearTimeout(watchedFilesSyncTimer);
      watchedFilesSyncTimer = null;
    }
    if (watchedDirsSyncTimer) {
      clearTimeout(watchedDirsSyncTimer);
      watchedDirsSyncTimer = null;
    }
    lastWatchedFilesKey = '';
    lastWatchedDirsKey = '';

    // Clear cooldown timestamps (no stale entries across editor sessions)
    recentSaveTimestamps.clear();
    recentMoveTimestamps.clear();

    // Best-effort IPC cleanup
    api.editor.close().catch((e: unknown) => {
      log.error('editor:close failed:', e);
    });

    // Cleanup bridge (destroys EditorView, clears caches)
    editorBridge.destroy();

    set({
      editorProjectPath: null,
      editorFileTree: null,
      editorFileTreeLoading: false,
      editorFileTreeError: null,
      editorExpandedDirs: {},
      editorOpenTabs: [],
      editorActiveTabId: null,
      editorFileLoading: {},
      editorModifiedFiles: {},
      editorSaving: {},
      editorSaveError: {},
      editorCreating: false,
      editorCreateError: null,
      editorGitFiles: [],
      editorGitBranch: null,
      editorIsGitRepo: false,
      editorGitLoading: false,
      editorWatcherEnabled: false,
      editorExternalChanges: {},
      editorFileMtimes: {},
      editorConflictFile: null,
      editorPendingGoToLine: null,
    });
  },

  loadFileTree: async (dirPath: string) => {
    set({ editorFileTreeLoading: true, editorFileTreeError: null });

    try {
      const t0 = performance.now();
      const result = await api.editor.readDir(dirPath);
      const ipcMs = performance.now() - t0;
      const t1 = performance.now();
      set({
        editorFileTree: result.entries,
        editorFileTreeLoading: false,
      });
      const setMs = performance.now() - t1;
      log.info(
        `[perf] loadFileTree: IPC=${ipcMs.toFixed(1)}ms, set=${setMs.toFixed(1)}ms, entries=${result.entries.length}`
      );
    } catch (error) {
      const message = error instanceof Error ? error.message : String(error);
      log.error('Failed to load file tree:', message);
      set({
        editorFileTreeLoading: false,
        editorFileTreeError: message,
      });
    }
  },

  expandDirectory: async (dirPath: string) => {
    const { editorExpandedDirs } = get();

    // Skip set() if already expanded — prevents unnecessary re-render
    const wasExpanded = !!editorExpandedDirs[dirPath];
    if (!wasExpanded) {
      set({
        editorExpandedDirs: { ...editorExpandedDirs, [dirPath]: true },
      });
      scheduleSyncWatchedDirs(get);
    }

    try {
      const t0 = performance.now();
      const result = await api.editor.readDir(dirPath);
      const ipcMs = performance.now() - t0;
      // Use fresh tree from store after await to avoid overwriting concurrent updates
      const currentTree = get().editorFileTree;
      const t1 = performance.now();
      const updatedTree = mergeChildrenIntoTree(currentTree ?? [], dirPath, result.entries);
      const mergeMs = performance.now() - t1;
      const t2 = performance.now();
      set({ editorFileTree: updatedTree });
      const setMs = performance.now() - t2;
      log.info(
        `[perf] expandDirectory: IPC=${ipcMs.toFixed(1)}ms, merge=${mergeMs.toFixed(1)}ms, set=${setMs.toFixed(1)}ms, entries=${result.entries.length}, wasExpanded=${wasExpanded}`
      );
    } catch (error) {
      const message = error instanceof Error ? error.message : String(error);
      log.error('Failed to expand directory:', message);
      const current = get().editorExpandedDirs;
      set({ editorExpandedDirs: omitKey(current, dirPath) });
    }
  },

  collapseDirectory: (dirPath: string) => {
    const { editorExpandedDirs } = get();
    set({ editorExpandedDirs: omitKey(editorExpandedDirs, dirPath) });
    scheduleSyncWatchedDirs(get);
  },

  // ═══════════════════════════════════════════════════════
  // Group 2: Tab management
  // ═══════════════════════════════════════════════════════

  openFile: (filePath: string) => {
    const { editorOpenTabs } = get();

    // Dedup: if file already open, just activate it
    const existing = editorOpenTabs.find((t) => t.filePath === filePath);
    if (existing) {
      set({ editorActiveTabId: existing.id });
      return;
    }

    const fileName = getBasename(filePath) || 'file';
    const language = getLanguageFromFileName(fileName);

    const tab: EditorFileTab = {
      id: filePath,
      filePath,
      fileName,
      language,
    };

    const newTabs = computeDisambiguatedTabs([...editorOpenTabs, tab]);

    set({
      editorOpenTabs: newTabs,
      editorActiveTabId: tab.id,
    });

    scheduleSyncWatchedFiles(get);
  },

  closeEditorTab: (tabId: string) => {
    const { editorOpenTabs, editorActiveTabId, editorModifiedFiles, editorSaveError } = get();
    const filtered = editorOpenTabs.filter((t) => t.id !== tabId);

    // Clean up dirty/error state for closed tab
    const restModified = omitKey(editorModifiedFiles, tabId);
    const restErrors = omitKey(editorSaveError, tabId);

    // Clear cached EditorState from bridge
    editorBridge.deleteState(tabId);

    // Clear draft from localStorage
    try {
      localStorage.removeItem(`editor-draft:${tabId}`);
    } catch {
      // localStorage may not be available
    }

    let newActiveId = editorActiveTabId;
    if (editorActiveTabId === tabId) {
      // Activate adjacent tab
      const closedIndex = editorOpenTabs.findIndex((t) => t.id === tabId);
      if (filtered.length > 0) {
        newActiveId = filtered[Math.min(closedIndex, filtered.length - 1)].id;
      } else {
        newActiveId = null;
      }
    }

    // Recompute disambiguation after removing tab
    const disambiguated = computeDisambiguatedTabs(filtered);

    set({
      editorOpenTabs: disambiguated,
      editorActiveTabId: newActiveId,
      editorModifiedFiles: restModified,
      editorSaveError: restErrors,
    });

    scheduleSyncWatchedFiles(get);
  },

  closeOtherEditorTabs: (keepTabId: string) => {
    const { editorOpenTabs } = get();
    const toClose = editorOpenTabs.filter((t) => t.id !== keepTabId);
    for (const tab of toClose) get().closeEditorTab(tab.id);
  },

  closeEditorTabsToLeft: (tabId: string) => {
    const { editorOpenTabs } = get();
    const idx = editorOpenTabs.findIndex((t) => t.id === tabId);
    if (idx <= 0) return;
    const toClose = editorOpenTabs.slice(0, idx);
    for (const tab of toClose) get().closeEditorTab(tab.id);
  },

  closeEditorTabsToRight: (tabId: string) => {
    const { editorOpenTabs } = get();
    const idx = editorOpenTabs.findIndex((t) => t.id === tabId);
    if (idx < 0 || idx >= editorOpenTabs.length - 1) return;
    const toClose = editorOpenTabs.slice(idx + 1);
    for (const tab of toClose) get().closeEditorTab(tab.id);
  },

  closeAllEditorTabs: () => {
    const { editorOpenTabs } = get();
    for (const tab of [...editorOpenTabs]) get().closeEditorTab(tab.id);
  },

  setActiveEditorTab: (tabId: string) => {
    set({ editorActiveTabId: tabId });
  },

  reorderEditorTabs: (activeId: string, overId: string) => {
    if (activeId === overId) return;
    const { editorOpenTabs } = get();
    const oldIndex = editorOpenTabs.findIndex((t) => t.id === activeId);
    const newIndex = editorOpenTabs.findIndex((t) => t.id === overId);
    if (oldIndex === -1 || newIndex === -1) return;

    const updated = [...editorOpenTabs];
    const [moved] = updated.splice(oldIndex, 1);
    updated.splice(newIndex, 0, moved);
    set({ editorOpenTabs: updated });
  },

  // ═══════════════════════════════════════════════════════
  // Group 3: Content + Save
  // ═══════════════════════════════════════════════════════

  markFileModified: (filePath: string) => {
    const { editorModifiedFiles } = get();
    if (editorModifiedFiles[filePath]) return; // Already marked
    set({ editorModifiedFiles: { ...editorModifiedFiles, [filePath]: true } });
  },

  markFileSaved: (filePath: string) => {
    const { editorModifiedFiles } = get();
    set({ editorModifiedFiles: omitKey(editorModifiedFiles, filePath) });
  },

  saveFile: async (filePath: string) => {
    const content = editorBridge.getContent(filePath);
    if (content === null) {
      log.error('saveFile: no content available for', filePath);
      return;
    }

    set((s) => ({
      editorSaving: { ...s.editorSaving, [filePath]: true },
      editorSaveError: omitKey(s.editorSaveError, filePath),
    }));

    try {
      // Pass baseline mtime for conflict detection (if available)
      const baselineMtime = get().editorFileMtimes[filePath];
      const result = await api.editor.writeFile(filePath, content, baselineMtime);

      // Record save timestamp BEFORE clearing editorSaving (watcher race guard)
      recentSaveTimestamps.set(filePath, Date.now());

      // Update baseline mtime with the new value after successful save
      set((s) => ({
        editorModifiedFiles: omitKey(s.editorModifiedFiles, filePath),
        editorSaving: omitKey(s.editorSaving, filePath),
        editorFileMtimes: { ...s.editorFileMtimes, [filePath]: result.mtimeMs },
        editorExternalChanges: omitKey(s.editorExternalChanges, filePath),
      }));

      try {
        localStorage.removeItem(`editor-draft:${filePath}`);
      } catch {
        // localStorage may not be available
      }
    } catch (error) {
      const message = error instanceof Error ? error.message : String(error);

      // Handle conflict errors specifically
      if (message.startsWith('CONFLICT')) {
        log.error('Save conflict detected:', filePath);
        set((s) => ({
          editorSaving: omitKey(s.editorSaving, filePath),
          editorConflictFile: filePath,
        }));
        return;
      }

      log.error('Failed to save file:', message);
      set((s) => ({
        editorSaving: omitKey(s.editorSaving, filePath),
        editorSaveError: { ...s.editorSaveError, [filePath]: message },
      }));
    }
  },

  saveAllFiles: async () => {
    const { editorModifiedFiles } = get();
    const modifiedContent = editorBridge.getAllModifiedContent(editorModifiedFiles);

    const promises: Promise<void>[] = [];
    for (const [filePath, content] of modifiedContent) {
      promises.push(
        (async () => {
          set((s) => ({
            editorSaving: { ...s.editorSaving, [filePath]: true },
          }));

          try {
            const baselineMtime = get().editorFileMtimes[filePath];
            const result = await api.editor.writeFile(filePath, content, baselineMtime);

            // Record save timestamp BEFORE clearing editorSaving (watcher race guard)
            recentSaveTimestamps.set(filePath, Date.now());

            set((s) => ({
              editorModifiedFiles: omitKey(s.editorModifiedFiles, filePath),
              editorSaving: omitKey(s.editorSaving, filePath),
              editorFileMtimes: { ...s.editorFileMtimes, [filePath]: result.mtimeMs },
              editorExternalChanges: omitKey(s.editorExternalChanges, filePath),
            }));
            try {
              localStorage.removeItem(`editor-draft:${filePath}`);
            } catch {
              // ignore
            }
          } catch (error) {
            const message = error instanceof Error ? error.message : String(error);

            if (message.startsWith('CONFLICT')) {
              log.error('Save conflict detected:', filePath);
              set((s) => ({
                editorSaving: omitKey(s.editorSaving, filePath),
                editorConflictFile: filePath,
              }));
              return;
            }

            log.error('Failed to save file:', filePath, message);
            set((s) => ({
              editorSaving: omitKey(s.editorSaving, filePath),
              editorSaveError: { ...s.editorSaveError, [filePath]: message },
            }));
          }
        })()
      );
    }

    await Promise.allSettled(promises);
  },

  discardChanges: (filePath: string) => {
    const { editorModifiedFiles, editorSaveError } = get();
    set({
      editorModifiedFiles: omitKey(editorModifiedFiles, filePath),
      editorSaveError: omitKey(editorSaveError, filePath),
    });

    try {
      localStorage.removeItem(`editor-draft:${filePath}`);
    } catch {
      // localStorage may not be available
    }
  },

  hasUnsavedChanges: () => {
    return Object.keys(get().editorModifiedFiles).length > 0;
  },

  // ═══════════════════════════════════════════════════════
  // Group 4: File operations
  // ═══════════════════════════════════════════════════════

  createFileInTree: async (parentDir: string, fileName: string) => {
    set({ editorCreating: true, editorCreateError: null });

    try {
      const result = await api.editor.createFile(parentDir, fileName);

      // Refresh parent directory in the tree
      await refreshDirectory(get, set, parentDir);

      set({ editorCreating: false });
      return result.filePath;
    } catch (error) {
      const message = error instanceof Error ? error.message : String(error);
      log.error('Failed to create file:', message);
      set({ editorCreating: false, editorCreateError: message });
      return null;
    }
  },

  createDirInTree: async (parentDir: string, dirName: string) => {
    set({ editorCreating: true, editorCreateError: null });

    try {
      const result = await api.editor.createDir(parentDir, dirName);

      // Refresh parent directory in the tree
      await refreshDirectory(get, set, parentDir);

      set({ editorCreating: false });
      return result.dirPath;
    } catch (error) {
      const message = error instanceof Error ? error.message : String(error);
      log.error('Failed to create directory:', message);
      set({ editorCreating: false, editorCreateError: message });
      return null;
    }
  },

  deleteFileFromTree: async (filePath: string) => {
    try {
      await api.editor.deleteFile(filePath);

      // Close tab if the deleted file is open
      const { editorOpenTabs } = get();
      const tabsToClose = editorOpenTabs.filter((t) => isPathPrefix(filePath, t.filePath));
      for (const tab of tabsToClose) {
        get().closeEditorTab(tab.id);
      }

      // Refresh parent directory
      const parentDir = filePath.substring(0, lastSeparatorIndex(filePath));
      if (parentDir) {
        await refreshDirectory(get, set, parentDir);
      }

      return true;
    } catch (error) {
      const message = error instanceof Error ? error.message : String(error);
      log.error('Failed to delete file:', message);
      return false;
    }
  },

  moveFileInTree: async (sourcePath: string, destDir: string) => {
    const { editorSaving } = get();

    // Guard: don't move during save
    if (editorSaving[sourcePath]) {
      log.error('moveFileInTree: blocked — file is being saved:', sourcePath);
      return false;
    }

    try {
      const result = await api.editor.moveFile(sourcePath, destDir);
      const { newPath, isDirectory } = result;
      const oldParent = sourcePath.substring(0, lastSeparatorIndex(sourcePath));

      // Record move timestamps for watcher cooldown
      recentMoveTimestamps.set(sourcePath, Date.now());
      recentMoveTimestamps.set(newPath, Date.now());

      // Atomic remap of all path-keyed state
      set((s) => {
        const tabs = s.editorOpenTabs.map((tab) => {
          const remapped = remapPath(tab.filePath, sourcePath, newPath);
          if (remapped === tab.filePath) return tab;
          const fileName = getBasename(remapped) || 'file';
          return {
            ...tab,
            id: remapped,
            filePath: remapped,
            fileName,
            language: getLanguageFromFileName(fileName),
          };
        });

        return {
          editorOpenTabs: computeDisambiguatedTabs(tabs),
          editorActiveTabId:
            remapPath(s.editorActiveTabId ?? '', sourcePath, newPath) || s.editorActiveTabId,
          editorModifiedFiles: remapRecord(s.editorModifiedFiles, sourcePath, newPath),
          editorSaving: remapRecord(s.editorSaving, sourcePath, newPath),
          editorSaveError: remapRecord(s.editorSaveError, sourcePath, newPath),
          editorFileLoading: remapRecord(s.editorFileLoading, sourcePath, newPath),
          editorExternalChanges: remapRecord(s.editorExternalChanges, sourcePath, newPath),
          editorFileMtimes: remapRecord(s.editorFileMtimes, sourcePath, newPath),
          editorExpandedDirs: remapRecord(s.editorExpandedDirs, sourcePath, newPath),
        };
      });

      // Keep open-files-only watcher in sync with remapped tab paths
      scheduleSyncWatchedFiles(get);

      // Remap bridge state for each affected tab
      const { editorOpenTabs } = get();
      for (const tab of editorOpenTabs) {
        // Check if this tab was affected by the move
        const originalPath = reverseRemapPath(tab.filePath, sourcePath, newPath);
        if (originalPath !== tab.filePath) {
          editorBridge.remapState(originalPath, tab.filePath);
        }
      }
      // Also remap for single file case (directories have no direct bridge state)
      if (!isDirectory) {
        editorBridge.remapState(sourcePath, newPath);
      }

      // Remap localStorage drafts
      try {
        for (let i = 0; i < localStorage.length; i++) {
          const key = localStorage.key(i);
          if (key?.startsWith('editor-draft:')) {
            const draftPath = key.slice('editor-draft:'.length);
            const remapped = remapPath(draftPath, sourcePath, newPath);
            if (remapped !== draftPath) {
              const value = localStorage.getItem(key);
              localStorage.removeItem(key);
              if (value !== null) localStorage.setItem(`editor-draft:${remapped}`, value);
            }
          }
        }
      } catch {
        // localStorage may not be available
      }

      // Remap recentSaveTimestamps
      for (const [key, ts] of [...recentSaveTimestamps.entries()]) {
        const remapped = remapPath(key, sourcePath, newPath);
        if (remapped !== key) {
          recentSaveTimestamps.delete(key);
          recentSaveTimestamps.set(remapped, ts);
        }
      }

      // Refresh directories and git status in background
      void refreshDirectory(get, set, oldParent);
      void refreshDirectory(get, set, destDir);
      void get().fetchGitStatus();

      return true;
    } catch (error) {
      const message = error instanceof Error ? error.message : String(error);
      log.error('moveFileInTree failed:', message);
      return false;
    }
  },

  renameFileInTree: async (sourcePath: string, newName: string) => {
    const { editorSaving } = get();

    if (editorSaving[sourcePath]) {
      log.error('renameFileInTree: blocked — file is being saved:', sourcePath);
      return false;
    }

    try {
      const result = await api.editor.renameFile(sourcePath, newName);
      const { newPath, isDirectory } = result;
      const parentDir = sourcePath.substring(0, lastSeparatorIndex(sourcePath));

      recentMoveTimestamps.set(sourcePath, Date.now());
      recentMoveTimestamps.set(newPath, Date.now());

      set((s) => {
        const tabs = s.editorOpenTabs.map((tab) => {
          const remapped = remapPath(tab.filePath, sourcePath, newPath);
          if (remapped === tab.filePath) return tab;
          const fileName = getBasename(remapped) || 'file';
          return {
            ...tab,
            id: remapped,
            filePath: remapped,
            fileName,
            language: getLanguageFromFileName(fileName),
          };
        });

        return {
          editorOpenTabs: computeDisambiguatedTabs(tabs),
          editorActiveTabId:
            remapPath(s.editorActiveTabId ?? '', sourcePath, newPath) || s.editorActiveTabId,
          editorModifiedFiles: remapRecord(s.editorModifiedFiles, sourcePath, newPath),
          editorSaving: remapRecord(s.editorSaving, sourcePath, newPath),
          editorSaveError: remapRecord(s.editorSaveError, sourcePath, newPath),
          editorFileLoading: remapRecord(s.editorFileLoading, sourcePath, newPath),
          editorExternalChanges: remapRecord(s.editorExternalChanges, sourcePath, newPath),
          editorFileMtimes: remapRecord(s.editorFileMtimes, sourcePath, newPath),
          editorExpandedDirs: remapRecord(s.editorExpandedDirs, sourcePath, newPath),
        };
      });

      // Keep open-files-only watcher in sync with remapped tab paths
      scheduleSyncWatchedFiles(get);

      // Remap bridge state
      const { editorOpenTabs } = get();
      for (const tab of editorOpenTabs) {
        const originalPath = reverseRemapPath(tab.filePath, sourcePath, newPath);
        if (originalPath !== tab.filePath) {
          editorBridge.remapState(originalPath, tab.filePath);
        }
      }
      if (!isDirectory) {
        editorBridge.remapState(sourcePath, newPath);
      }

      // Remap localStorage drafts
      try {
        for (let i = 0; i < localStorage.length; i++) {
          const key = localStorage.key(i);
          if (key?.startsWith('editor-draft:')) {
            const draftPath = key.slice('editor-draft:'.length);
            const remapped = remapPath(draftPath, sourcePath, newPath);
            if (remapped !== draftPath) {
              const value = localStorage.getItem(key);
              localStorage.removeItem(key);
              if (value !== null) localStorage.setItem(`editor-draft:${remapped}`, value);
            }
          }
        }
      } catch {
        // localStorage may not be available
      }

      for (const [key, ts] of [...recentSaveTimestamps.entries()]) {
        const remapped = remapPath(key, sourcePath, newPath);
        if (remapped !== key) {
          recentSaveTimestamps.delete(key);
          recentSaveTimestamps.set(remapped, ts);
        }
      }

      void refreshDirectory(get, set, parentDir);
      void get().fetchGitStatus();

      return true;
    } catch (error) {
      const message = error instanceof Error ? error.message : String(error);
      log.error('renameFileInTree failed:', message);
      return false;
    }
  },

  // ═══════════════════════════════════════════════════════
  // Group 5: Git status + file watcher + line wrap
  // ═══════════════════════════════════════════════════════

  fetchGitStatus: async () => {
    set({ editorGitLoading: true });
    try {
      const t0 = performance.now();
      const result = await api.editor.gitStatus();
      const ipcMs = performance.now() - t0;
      const t1 = performance.now();
      set({
        editorGitFiles: result.files,
        editorGitBranch: result.branch,
        editorIsGitRepo: result.isGitRepo,
        editorGitLoading: false,
      });
      const setMs = performance.now() - t1;
      log.info(
        `[perf] fetchGitStatus: IPC=${ipcMs.toFixed(1)}ms, set=${setMs.toFixed(1)}ms, files=${result.files.length}`
      );
    } catch (error) {
      log.error('Failed to fetch git status:', error);
      set({ editorGitLoading: false });
    }
  },

  toggleWatcher: async (enable: boolean) => {
    try {
      await api.editor.watchDir(enable);
      set({ editorWatcherEnabled: enable });
      try {
        localStorage.setItem('editor-watcher-enabled', String(enable));
      } catch {
        // localStorage may not be available
      }
      if (enable) {
        scheduleSyncWatchedFiles(get);
        scheduleSyncWatchedDirs(get);
      } else {
        // Ensure main process stops watching files promptly.
        lastWatchedFilesKey = '';
        lastWatchedDirsKey = '';
        void api.editor.setWatchedFiles([]);
        void api.editor.setWatchedDirs([]);
      }
    } catch (error) {
      log.error('Failed to toggle watcher:', error);
    }
  },

  toggleLineWrap: () => {
    set((s) => {
      const next = !s.editorLineWrap;
      try {
        localStorage.setItem('editor-line-wrap', String(next));
      } catch {
        // localStorage may not be available
      }
      return { editorLineWrap: next };
    });
  },

  handleExternalFileChange: (event: EditorFileChangeEvent) => {
    // Avoid per-event logging (can freeze renderer during bursts on large repos).
    watcherEventCounts[event.type] = (watcherEventCounts[event.type] ?? 0) + 1;
    if (!watcherEventLogTimer) {
      watcherEventLogTimer = setTimeout(() => {
        watcherEventLogTimer = null;
        const counts = watcherEventCounts;
        watcherEventCounts = { change: 0, create: 0, delete: 0 };
        // Keep a single lightweight summary line.
        log.info(
          `[perf] editor watcher events (2s): change=${counts.change}, create=${counts.create}, delete=${counts.delete}`
        );
      }, 2000);
    }
    const { editorOpenTabs, editorProjectPath, editorSaving } = get();

    // Ignore watcher events for files we are currently saving (our own write)
    if (editorSaving[event.path]) return;

    // Ignore watcher events within cooldown after save
    // (covers race: save completes → editorSaving cleared → watcher fires late)
    const lastSaveTime = recentSaveTimestamps.get(event.path);
    if (lastSaveTime && Date.now() - lastSaveTime < SAVE_COOLDOWN_MS) return;

    // Ignore watcher events within cooldown after move
    const lastMoveTime = recentMoveTimestamps.get(event.path);
    if (lastMoveTime && Date.now() - lastMoveTime < MOVE_COOLDOWN_MS) return;

    // Track changes for open files
    const isOpenFile = editorOpenTabs.some((t) => t.filePath === event.path);
    if (isOpenFile || event.type === 'delete') {
      set((s) => ({
        editorExternalChanges: {
          ...s.editorExternalChanges,
          [event.path]: event.type,
        },
      }));
    }

    // Refresh git status on change — throttled to avoid expensive work during bursts.
    // Main process already caches git status for 5s, but IPC + store updates still cost.
    if (!gitStatusThrottleTimer) {
      gitStatusThrottleTimer = setTimeout(() => {
        gitStatusThrottleTimer = null;
        void get().fetchGitStatus();
      }, GIT_STATUS_THROTTLE_MS);
    }

    // Refresh parent directory in tree for create/delete
    if (event.type === 'create' || event.type === 'delete') {
      invalidateQuickOpenCache();
      const parentDir = event.path.substring(0, lastSeparatorIndex(event.path));
      if (parentDir && editorProjectPath) {
        const existing = dirRefreshDebounceTimers.get(parentDir);
        if (existing) clearTimeout(existing);
        const timer = setTimeout(() => {
          dirRefreshDebounceTimers.delete(parentDir);
          void refreshDirectory(get, set, parentDir);
        }, DIR_REFRESH_DEBOUNCE_MS);
        dirRefreshDebounceTimers.set(parentDir, timer);
      }
    }
  },

  clearExternalChange: (filePath: string) => {
    set((s) => ({
      editorExternalChanges: omitKey(s.editorExternalChanges, filePath),
    }));
  },

  setFileMtime: (filePath: string, mtimeMs: number) => {
    set((s) => ({
      editorFileMtimes: { ...s.editorFileMtimes, [filePath]: mtimeMs },
    }));
  },

  forceOverwrite: async (filePath: string) => {
    const content = editorBridge.getContent(filePath);
    if (content === null) {
      log.error('forceOverwrite: no content available for', filePath);
      return;
    }

    set((s) => ({
      editorSaving: { ...s.editorSaving, [filePath]: true },
      editorConflictFile: null,
    }));

    try {
      // No baselineMtimeMs → skip conflict check on backend
      const result = await api.editor.writeFile(filePath, content);

      // Record save timestamp BEFORE clearing editorSaving (watcher race guard)
      recentSaveTimestamps.set(filePath, Date.now());

      set((s) => ({
        editorModifiedFiles: omitKey(s.editorModifiedFiles, filePath),
        editorSaving: omitKey(s.editorSaving, filePath),
        editorFileMtimes: { ...s.editorFileMtimes, [filePath]: result.mtimeMs },
        editorExternalChanges: omitKey(s.editorExternalChanges, filePath),
      }));

      try {
        localStorage.removeItem(`editor-draft:${filePath}`);
      } catch {
        // localStorage may not be available
      }
    } catch (error) {
      const message = error instanceof Error ? error.message : String(error);
      log.error('Failed to force overwrite:', message);
      set((s) => ({
        editorSaving: omitKey(s.editorSaving, filePath),
        editorSaveError: { ...s.editorSaveError, [filePath]: message },
      }));
    }
  },

  resolveConflict: () => {
    set({ editorConflictFile: null });
  },
});

// =============================================================================
// Helpers
// =============================================================================

/**
 * Refresh a directory's children in the file tree via IPC readDir + merge.
 */
async function refreshDirectory(
  get: () => AppState,
  set: (partial: Partial<AppState>) => void,
  dirPath: string
): Promise<void> {
  try {
    const t0 = performance.now();
    const result = await api.editor.readDir(dirPath);
    log.info(
      `[perf] refreshDirectory: IPC=${(performance.now() - t0).toFixed(1)}ms, entries=${result.entries.length}, dir=${getBasename(dirPath)}`
    );
    const currentTree = get().editorFileTree;
    if (!currentTree) return;

    const projectPath = get().editorProjectPath;
    if (dirPath === projectPath) {
      // Root refresh — tree IS the root's children, so preserve expanded subtrees
      // by merging new entries with existing children data
      const existingByPath = new Map<string, FileTreeEntry>();
      for (const entry of currentTree) {
        existingByPath.set(entry.path, entry);
      }
      const merged = result.entries.map((entry) => {
        const existing = existingByPath.get(entry.path);
        // Preserve expanded subtree children for directories that still exist
        if (existing?.children && entry.type === 'directory') {
          return { ...entry, children: existing.children };
        }
        return entry;
      });
      set({ editorFileTree: merged });
    } else {
      const updatedTree = mergeChildrenIntoTree(currentTree, dirPath, result.entries);
      set({ editorFileTree: updatedTree });
    }
  } catch (error) {
    log.error('Failed to refresh directory:', error);
  }
}

/**
 * Remap a single path: if it matches oldPath exactly or is a child of oldPath,
 * replace the prefix with newPath.
 */
function remapPath(p: string, oldPath: string, newPath: string): string {
  const oldParts = splitPath(oldPath);
  const pParts = splitPath(p);
  if (oldParts.length === 0) return p;

  const win = isWindowsishPath(oldPath) || isWindowsishPath(p) || isWindowsishPath(newPath);
  const eq = (a: string, b: string): boolean =>
    win ? a.toLowerCase() === b.toLowerCase() : a === b;

  const matchesPrefix =
    pParts.length >= oldParts.length && oldParts.every((seg, i) => eq(seg, pParts[i]));
  if (!matchesPrefix) return p;

  const suffix = pParts.slice(oldParts.length);
  return suffix.length > 0 ? joinPath(newPath, ...suffix) : newPath;
}

/**
 * Reverse remap: given a potentially-remapped path, recover the original path.
 * Used to identify which bridge caches to remap.
 */
function reverseRemapPath(p: string, oldPath: string, newPath: string): string {
  const newParts = splitPath(newPath);
  const pParts = splitPath(p);
  if (newParts.length === 0) return p;

  const win = isWindowsishPath(oldPath) || isWindowsishPath(p) || isWindowsishPath(newPath);
  const eq = (a: string, b: string): boolean =>
    win ? a.toLowerCase() === b.toLowerCase() : a === b;

  const matchesPrefix =
    pParts.length >= newParts.length && newParts.every((seg, i) => eq(seg, pParts[i]));
  if (!matchesPrefix) return p;

  const suffix = pParts.slice(newParts.length);
  return suffix.length > 0 ? joinPath(oldPath, ...suffix) : oldPath;
}

/**
 * Remap all keys in a Record that match or are children of oldPath.
 */
function remapRecord<V>(
  record: Record<string, V>,
  oldPath: string,
  newPath: string
): Record<string, V> {
  const result: Record<string, V> = {};
  let changed = false;
  for (const [key, value] of Object.entries(record)) {
    const remapped = remapPath(key, oldPath, newPath);
    if (remapped !== key) changed = true;
    result[remapped] = value;
  }
  return changed ? result : record;
}

/**
 * Recursively merge children into the tree at the matching directory path.
 * Returns the same array reference if nothing changed — preserves React.memo equality.
 */
function mergeChildrenIntoTree(
  tree: FileTreeEntry[],
  targetPath: string,
  children: FileTreeEntry[]
): FileTreeEntry[] {
  let changed = false;
  const result = tree.map((entry) => {
    if (entry.path === targetPath && entry.type === 'directory') {
      changed = true;
      return { ...entry, children };
    }
    if (entry.children) {
      const updated = mergeChildrenIntoTree(entry.children, targetPath, children);
      if (updated !== entry.children) {
        changed = true;
        return { ...entry, children: updated };
      }
    }
    return entry;
  });
  return changed ? result : tree;
}
