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

import { ipcChannels, type ScenePreviewImportEvent } from '../../../shared/ipc/contracts';
import type {
  NpcImportResolution,
  SceneImportResolution,
  StorylineImportMergeReport,
  StorylineLabels,
  StorylineListItem,
  StorylineSelection,
} from '../../../shared/graph/storylineExportImport';
import type {
  AssetId,
  GraphNodeId,
  MaterialId,
  Project,
  ProjectId,
  Scene,
  SceneId,
} from '../../../shared/types';
import { getDndApi } from '../../shared/dndApi';
import { invalidateAssetUrlCache } from '../../shared/useAssetImageUrl';
import { applyPreviewRotationToSceneMarkers } from '../../../shared/types/scenePreviewRotation';

type ProjectSummary = { id: ProjectId; name: string; updatedAt: string; fileName: string };

type State = {
  projects: ProjectSummary[];
  project: Project | null;
  selectedSceneId: SceneId | null;
  openingProjectId: ProjectId | null;
  creatingScene: boolean;
  sceneBatchImport: { current: number; total: number; fileName: string } | null;
  zipProgress: { kind: 'import' | 'export'; percent: number; stage: string; detail?: string } | null;
  scenePreviewImports: Record<
    SceneId,
    { assetId: AssetId; phase: ScenePreviewImportEvent['phase']; message?: string }
  >;
};

type Actions = {
  refreshProjects: () => Promise<void>;
  createProject: (name: string) => Promise<void>;
  openProject: (id: ProjectId) => Promise<void>;
  closeProject: () => Promise<void>;
  createScene: () => Promise<void>;
  createScenesFromMediaPaths: (
    items: { filePath: string; title: string }[],
  ) => Promise<{ created: number; lastSceneId: SceneId | null }>;
  selectScene: (id: SceneId) => Promise<void>;
  importCampaignAudio: () => Promise<void>;
  importCampaignAudioFromPaths: (filePaths: string[]) => Promise<void>;
  updateCampaignAudios: (next: Project['campaignAudios']) => Promise<void>;
  upsertMaterial: (input: { materialId?: MaterialId; name: string; filePath?: string }) => Promise<void>;
  deleteMaterial: (materialId: MaterialId) => Promise<void>;
  setMaterialsOrder: (materialIds: MaterialId[]) => Promise<void>;
  setMaterialRotation: (materialId: MaterialId, rotationDeg: 0 | 90 | 180 | 270) => Promise<void>;
  setMaterialLegend: (
    materialId: MaterialId,
    legend: import('../../../shared/types').MaterialLegend | null,
  ) => Promise<void>;
  pickMaterialImage: () => Promise<{ filePath: string; previewDataUrl: string } | null>;
  updateScene: (
    sceneId: SceneId,
    patch: {
      title?: string;
      description?: string;
      previewAssetId?: AssetId | null;
      previewThumbAssetId?: AssetId | null;
      previewAssetType?: 'image' | 'video' | null;
      previewVideoAutostart?: boolean;
      previewRotationDeg?: 0 | 90 | 180 | 270;
      darkenScene?: boolean;
      settings?: Partial<Scene['settings']>;
      media?: Partial<Scene['media']>;
      layout?: { x: number; y: number };
    },
  ) => Promise<void>;
  updateConnections: (sceneId: SceneId, connections: SceneId[]) => Promise<void>;
  importMediaToScene: (sceneId: SceneId) => Promise<void>;
  importMediaToSceneFromPaths: (sceneId: SceneId, filePaths: string[]) => Promise<void>;
  importScenePreview: (sceneId: SceneId) => Promise<{ assetId: AssetId | null; background: boolean }>;
  importScenePreviewFromPath: (
    sceneId: SceneId,
    filePath: string,
  ) => Promise<{ assetId: AssetId | null; background: boolean }>;
  clearScenePreview: (sceneId: SceneId) => Promise<void>;
  updateSceneGraphNodePosition: (nodeId: GraphNodeId, x: number, y: number) => Promise<void>;
  addSceneGraphNode: (sceneId: SceneId, x: number, y: number) => Promise<void>;
  removeSceneGraphNode: (nodeId: GraphNodeId) => Promise<void>;
  addSceneGraphEdge: (sourceGraphNodeId: GraphNodeId, targetGraphNodeId: GraphNodeId) => Promise<void>;
  removeSceneGraphEdge: (edgeId: string) => Promise<void>;
  setSceneGraphNodeStart: (graphNodeId: GraphNodeId | null) => Promise<void>;
  setSceneGraphNodeSideStoryStart: (graphNodeId: GraphNodeId) => Promise<void>;
  updateSideStoryLineTitle: (graphNodeId: GraphNodeId, title: string) => Promise<void>;
  deleteScene: (sceneId: SceneId) => Promise<void>;
  setSceneListOrder: (sceneListOrder: SceneId[]) => Promise<void>;
  renameProject: (name: string, fileBaseName: string) => Promise<void>;
  importProject: () => Promise<void>;
  pickFoundrySource: (
    mode: 'folder' | 'archive',
  ) => Promise<{ canceled: true } | { canceled: false; sourcePath: string }>;
  importFoundryProject: (sourcePath: string) => Promise<void>;
  peekImportZip: (
    labels: StorylineLabels,
    targetHasMainStart: boolean,
  ) => Promise<
    | { canceled: true }
    | {
        canceled: false;
        filePath: string;
        projectName: string;
        storylines: StorylineListItem[];
        sourceProject: Project;
      }
  >;
  pickImportZipFile: () => Promise<{ canceled: true } | { canceled: false; filePath: string }>;
  peekImportZipPath: (
    filePath: string,
    labels: StorylineLabels,
    targetHasMainStart: boolean,
  ) => Promise<{
    filePath: string;
    projectName: string;
    storylines: StorylineListItem[];
    sourceProject: Project;
  }>;
  peekImportFromProject: (
    sourceProjectId: ProjectId,
    labels: StorylineLabels,
    targetHasMainStart: boolean,
  ) => Promise<{
    sourceProjectId: ProjectId;
    projectName: string;
    storylines: StorylineListItem[];
    sourceProject: Project;
  }>;
  mergeImportZip: (
    filePath: string,
    storylineSelections: StorylineSelection[],
    sceneResolutions: SceneImportResolution[],
    npcResolutions?: NpcImportResolution[],
  ) => Promise<{ project: Project; report: StorylineImportMergeReport }>;
  mergeImportFromProject: (
    sourceProjectId: ProjectId,
    storylineSelections: StorylineSelection[],
    sceneResolutions: SceneImportResolution[],
    npcResolutions?: NpcImportResolution[],
  ) => Promise<{ project: Project; report: StorylineImportMergeReport }>;
  importProjectFromPath: (filePath: string) => Promise<void>;
  getProjectStorylines: (
    projectId: ProjectId,
    labels: StorylineLabels,
  ) => Promise<{ storylines: StorylineListItem[]; npcs: { id: string; name: string }[] }>;
  exportProject: (
    projectId: ProjectId,
    storylineSelections: StorylineSelection[],
    npcIds: string[],
    labels: StorylineLabels,
  ) => Promise<void>;
  deleteProject: (projectId: ProjectId) => Promise<void>;
};

function randomId(prefix: string): string {
  return `${prefix}_${Math.random().toString(16).slice(2)}_${Date.now().toString(16)}`;
}

export type ProjectNoticeCode = 'campaign_audio_empty';

export type ProjectStateOpts = {
  onNotice?: (code: ProjectNoticeCode) => void;
};

export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts): readonly [State, Actions] {
  const api = getDndApi();
  const onNoticeRef = useRef(opts?.onNotice);
  useEffect(() => {
    onNoticeRef.current = opts?.onNotice;
  }, [opts?.onNotice]);
  const [state, setState] = useState<State>({
    projects: [],
    project: null,
    selectedSceneId: null,
    openingProjectId: null,
    creatingScene: false,
    sceneBatchImport: null,
    zipProgress: null,
    scenePreviewImports: {} as Record<
      SceneId,
      { assetId: AssetId; phase: ScenePreviewImportEvent['phase']; message?: string }
    >,
  });
  const projectRef = useRef<Project | null>(null);
  const openInFlightRef = useRef<Promise<void> | null>(null);
  const createSceneInFlightRef = useRef<Promise<void> | null>(null);
  /** Bumps on mutations / refresh; initial license load only applies if still current (avoids racing late list/get over newer state). */
  const projectDataEpochRef = useRef(0);
  useEffect(() => {
    projectRef.current = state.project;
  }, [state.project]);

  useEffect(() => {
    const offImport = api.on(ipcChannels.project.importZipProgress, (evt) => {
      const e = evt as unknown as { percent: number; stage: string; detail?: string };
      setState((s) => ({
        ...s,
        zipProgress: {
          kind: 'import',
          percent: e.percent,
          stage: e.stage,
          ...(e.detail ? { detail: e.detail } : null),
        },
      }));
      if (e.stage === 'done' || e.stage === 'error' || e.percent >= 100) {
        const delay = e.stage === 'error' ? 0 : 450;
        setTimeout(() => setState((s) => ({ ...s, zipProgress: null })), delay);
      }
    });
    const offExport = api.on(ipcChannels.project.exportZipProgress, (evt) => {
      const e = evt as unknown as { percent: number; stage: string; detail?: string };
      setState((s) => ({
        ...s,
        zipProgress: {
          kind: 'export',
          percent: e.percent,
          stage: e.stage,
          ...(e.detail ? { detail: e.detail } : null),
        },
      }));
      if (e.stage === 'done' || e.stage === 'error' || e.percent >= 100) {
        const delay = e.stage === 'error' ? 0 : 450;
        setTimeout(() => setState((s) => ({ ...s, zipProgress: null })), delay);
      }
    });
    const offPreview = api.on(ipcChannels.project.scenePreviewImportProgress, (evt) => {
      setState((s) => {
        const nextImports = { ...s.scenePreviewImports };
        nextImports[evt.sceneId] = {
          assetId: evt.assetId,
          phase: evt.phase,
          ...(evt.message ? { message: evt.message } : null),
        };
        return {
          ...s,
          ...(evt.project ? { project: evt.project } : null),
          scenePreviewImports: nextImports,
        };
      });
      if (evt.phase === 'done' || evt.phase === 'error') {
        setTimeout(
          () => {
            setState((s) => {
              const cur = s.scenePreviewImports[evt.sceneId];
              if (cur?.assetId !== evt.assetId || cur.phase !== evt.phase) return s;
              const nextImports = Object.fromEntries(
                Object.entries(s.scenePreviewImports).filter(([sceneId]) => sceneId !== evt.sceneId),
              ) as State['scenePreviewImports'];
              return { ...s, scenePreviewImports: nextImports };
            });
          },
          evt.phase === 'done' ? 1000 : 4000,
        );
      }
    });
    return () => {
      offImport();
      offExport();
      offPreview();
    };
  }, [api]);

  const actions = useMemo<Actions>(() => {
    const refreshProjects = async () => {
      projectDataEpochRef.current += 1;
      const res = await api.invoke(ipcChannels.project.list, {});
      setState((s) => ({ ...s, projects: res.projects }));
    };

    const createProject = async (name: string) => {
      const res = await api.invoke(ipcChannels.project.create, { name });
      setState((s) => ({ ...s, project: res.project, selectedSceneId: res.project.currentSceneId }));
      await refreshProjects();
    };

    const openProject = async (id: ProjectId) => {
      projectDataEpochRef.current += 1;
      const epoch = projectDataEpochRef.current;
      openInFlightRef.current = null;
      // URL ассетов зависят от открытого проекта — сбрасываем renderer-кэш.
      invalidateAssetUrlCache();

      const job = (async () => {
        // При открытии не выбираем сцену: список/граф/инспектор без выделения.
        setState((s) => ({ ...s, openingProjectId: id, selectedSceneId: null }));
        try {
          const res = await api.invoke(ipcChannels.project.open, { projectId: id });
          if (projectDataEpochRef.current !== epoch) {
            setState((s) => ({ ...s, openingProjectId: null }));
            return;
          }
          setState((s) => ({
            ...s,
            project: res.project,
            selectedSceneId: null,
            openingProjectId: null,
          }));
        } catch {
          if (projectDataEpochRef.current === epoch) {
            setState((s) => ({ ...s, openingProjectId: null }));
          }
        }
      })();
      openInFlightRef.current = job;
      void job.finally(() => {
        if (openInFlightRef.current === job) openInFlightRef.current = null;
      });
      return job;
    };

    const closeProject = async () => {
      projectDataEpochRef.current += 1;
      openInFlightRef.current = null;
      invalidateAssetUrlCache();
      try {
        await api.invoke(ipcChannels.project.close, {});
      } finally {
        setState((s) => ({
          ...s,
          project: null,
          selectedSceneId: null,
          openingProjectId: null,
          zipProgress: null,
        }));
        await refreshProjects();
      }
    };

    const createScene = async () => {
      if (createSceneInFlightRef.current) return createSceneInFlightRef.current;
      const p = projectRef.current;
      if (!p) return;
      const job = (async () => {
        setState((s) => ({ ...s, creatingScene: true }));
        try {
          const sceneId = randomId('scene') as SceneId;
          const scene: Scene = {
            id: sceneId,
            title: `Новая сцена`,
            description: '',
            previewAssetId: null,
            previewThumbAssetId: null,
            previewAssetType: null,
            previewVideoAutostart: false,
            previewRotationDeg: 0,
            darkenScene: false,
            traps: [],
            tokens: [],
            npcTokens: [],
            grid: { enabled: false, type: 'square', sizeN: 0.06, color: '#ffffff' },
            media: { videos: [], audios: [] },
            settings: { autoplayVideo: false, autoplayAudio: true, loopVideo: true, loopAudio: true },
            connections: [],
            layout: { x: 0, y: 0 },
          };
          await api.invoke(ipcChannels.project.updateScene, {
            sceneId,
            patch: {
              title: scene.title,
              description: scene.description,
              media: scene.media,
              settings: scene.settings,
              layout: scene.layout,
              previewAssetId: scene.previewAssetId,
              previewAssetType: scene.previewAssetType,
              previewVideoAutostart: scene.previewVideoAutostart,
            },
          });
          await api.invoke(ipcChannels.project.setCurrentScene, { sceneId });
          const res = await api.invoke(ipcChannels.project.get, {});
          setState((s) => ({ ...s, project: res.project, selectedSceneId: sceneId }));
        } finally {
          setState((s) => ({ ...s, creatingScene: false }));
        }
      })();
      createSceneInFlightRef.current = job;
      void job.finally(() => {
        if (createSceneInFlightRef.current === job) createSceneInFlightRef.current = null;
      });
      return job;
    };

    const createScenesFromMediaPaths = async (
      items: { filePath: string; title: string }[],
    ): Promise<{ created: number; lastSceneId: SceneId | null }> => {
      if (items.length === 0) return { created: 0, lastSceneId: null };
      if (createSceneInFlightRef.current) {
        await createSceneInFlightRef.current;
      }
      const p = projectRef.current;
      if (!p) return { created: 0, lastSceneId: null };

      let created = 0;
      let lastSceneId: SceneId | null = null;
      const job = (async () => {
        setState((s) => ({
          ...s,
          creatingScene: true,
          sceneBatchImport: { current: 0, total: items.length, fileName: items[0]?.title ?? '' },
        }));
        try {
          for (let i = 0; i < items.length; i += 1) {
            const item = items[i]!;
            setState((s) => ({
              ...s,
              sceneBatchImport: {
                current: i + 1,
                total: items.length,
                fileName: item.title,
              },
            }));
            const sceneId = randomId('scene') as SceneId;
            await api.invoke(ipcChannels.project.updateScene, {
              sceneId,
              patch: {
                title: item.title,
                description: '',
                media: { videos: [], audios: [] },
                settings: {
                  autoplayVideo: false,
                  autoplayAudio: true,
                  loopVideo: true,
                  loopAudio: true,
                },
                layout: { x: 0, y: 0 },
                previewAssetId: null,
                previewAssetType: null,
                previewVideoAutostart: false,
              },
            });
            const previewRes = await api.invoke(ipcChannels.project.importScenePreview, {
              sceneId,
              filePath: item.filePath,
            });
            setState((s) => {
              const nextImports = { ...s.scenePreviewImports };
              if (previewRes.assetId !== null && previewRes.background) {
                nextImports[sceneId] = { assetId: previewRes.assetId, phase: 'queued' };
              }
              return {
                ...s,
                project: previewRes.project,
                scenePreviewImports: nextImports,
              };
            });
            created += 1;
            lastSceneId = sceneId;
          }
          if (lastSceneId) {
            await api.invoke(ipcChannels.project.setCurrentScene, { sceneId: lastSceneId });
            const res = await api.invoke(ipcChannels.project.get, {});
            setState((s) => ({
              ...s,
              project: res.project,
              selectedSceneId: lastSceneId,
            }));
          }
          await refreshProjects();
        } finally {
          setState((s) => ({
            ...s,
            creatingScene: false,
            sceneBatchImport: null,
          }));
        }
      })();
      createSceneInFlightRef.current = job;
      void job.finally(() => {
        if (createSceneInFlightRef.current === job) createSceneInFlightRef.current = null;
      });
      await job;
      return { created, lastSceneId };
    };

    const selectScene = async (id: SceneId) => {
      setState((s) => ({ ...s, selectedSceneId: id }));
      await api.invoke(ipcChannels.project.setCurrentScene, { sceneId: id });
    };

    const importCampaignAudio = async () => {
      const res = await api.invoke(ipcChannels.project.importCampaignAudio, {});
      if (res.canceled) return;
      if (res.imported.length === 0) {
        onNoticeRef.current?.('campaign_audio_empty');
      }
      setState((s) => ({ ...s, project: res.project }));
      await refreshProjects();
    };

    const importCampaignAudioFromPaths = async (filePaths: string[]) => {
      if (filePaths.length === 0) return;
      const res = await api.invoke(ipcChannels.project.importCampaignAudio, { filePaths });
      if (res.imported.length === 0) return;
      setState((s) => ({ ...s, project: res.project }));
      await refreshProjects();
    };

    const updateCampaignAudios = async (next: Project['campaignAudios']) => {
      const res = await api.invoke(ipcChannels.project.updateCampaignAudios, { audios: next });
      setState((s) => ({ ...s, project: res.project }));
      await refreshProjects();
    };

    const upsertMaterial = async (input: { materialId?: MaterialId; name: string; filePath?: string }) => {
      const res = await api.invoke(ipcChannels.project.upsertMaterial, input);
      setState((s) => ({ ...s, project: res.project }));
      await refreshProjects();
    };

    const deleteMaterial = async (materialId: MaterialId) => {
      const res = await api.invoke(ipcChannels.project.deleteMaterial, { materialId });
      setState((s) => ({ ...s, project: res.project }));
      await refreshProjects();
    };

    const setMaterialsOrder = async (materialIds: MaterialId[]) => {
      const res = await api.invoke(ipcChannels.project.setMaterialsOrder, { materialIds });
      setState((s) => ({ ...s, project: res.project }));
      await refreshProjects();
    };

    const setMaterialRotation = async (materialId: MaterialId, rotationDeg: 0 | 90 | 180 | 270) => {
      const res = await api.invoke(ipcChannels.project.setMaterialRotation, {
        materialId,
        rotationDeg,
      });
      setState((s) => ({ ...s, project: res.project }));
      await refreshProjects();
    };

    const setMaterialLegend = async (
      materialId: MaterialId,
      legend: import('../../../shared/types').MaterialLegend | null,
    ) => {
      const res = await api.invoke(ipcChannels.project.setMaterialLegend, { materialId, legend });
      // Список проектов не меняется — не дергаем refreshProjects на каждое обновление легенды.
      setState((s) => ({ ...s, project: res.project }));
    };

    const pickMaterialImage = async () => {
      const res = await api.invoke(ipcChannels.project.pickMaterialImage, {});
      if (res.canceled) return null;
      return { filePath: res.filePath, previewDataUrl: res.previewDataUrl };
    };

    const updateScene = async (
      sceneId: SceneId,
      patch: {
        title?: string;
        description?: string;
        previewAssetId?: AssetId | null;
        previewThumbAssetId?: AssetId | null;
        previewAssetType?: 'image' | 'video' | null;
        previewVideoAutostart?: boolean;
        previewRotationDeg?: 0 | 90 | 180 | 270;
        darkenScene?: boolean;
        traps?: import('../../../shared/types').SceneTrap[];
        tokens?: import('../../../shared/types').SceneToken[];
        npcTokens?: import('../../../shared/types').SceneNpcToken[];
        grid?: import('../../../shared/types').SceneGrid;
        settings?: Partial<Scene['settings']>;
        media?: Partial<Scene['media']>;
        layout?: { x: number; y: number };
      },
    ) => {
      setState((s) => {
        const p = s.project;
        if (!p) return s;
        const scene = p.scenes[sceneId];
        if (!scene) return s;
        const next: Scene = {
          ...scene,
          ...(patch.title !== undefined ? { title: patch.title } : null),
          ...(patch.description !== undefined ? { description: patch.description } : null),
          ...(patch.previewAssetId !== undefined ? { previewAssetId: patch.previewAssetId } : null),
          ...(patch.previewThumbAssetId !== undefined
            ? { previewThumbAssetId: patch.previewThumbAssetId }
            : null),
          ...(patch.previewAssetType !== undefined ? { previewAssetType: patch.previewAssetType } : null),
          ...(patch.previewVideoAutostart !== undefined
            ? { previewVideoAutostart: patch.previewVideoAutostart }
            : null),
          ...(patch.previewRotationDeg !== undefined
            ? { previewRotationDeg: patch.previewRotationDeg }
            : null),
          ...(patch.darkenScene !== undefined ? { darkenScene: patch.darkenScene } : null),
          ...(patch.traps !== undefined ? { traps: patch.traps } : null),
          ...(patch.tokens !== undefined ? { tokens: patch.tokens } : null),
          ...(patch.npcTokens !== undefined ? { npcTokens: patch.npcTokens } : null),
          ...(patch.grid !== undefined ? { grid: patch.grid } : null),
          ...(patch.settings ? { settings: { ...scene.settings, ...patch.settings } } : null),
          ...(patch.media ? { media: { ...scene.media, ...patch.media } } : null),
          layout: patch.layout ? { ...scene.layout, ...patch.layout } : scene.layout,
        };
        if (patch.previewRotationDeg !== undefined) {
          const remapped = applyPreviewRotationToSceneMarkers(
            {
              previewRotationDeg: scene.previewRotationDeg ?? 0,
              tokens: scene.tokens ?? [],
              npcTokens: scene.npcTokens ?? [],
              traps: scene.traps ?? [],
            },
            patch.previewRotationDeg,
            {
              tokensProvided: patch.tokens !== undefined,
              npcTokensProvided: patch.npcTokens !== undefined,
              trapsProvided: patch.traps !== undefined,
            },
          );
          if (patch.tokens === undefined) next.tokens = remapped.tokens;
          if (patch.npcTokens === undefined) next.npcTokens = remapped.npcTokens;
          if (patch.traps === undefined) next.traps = remapped.traps;
        }
        const scenes = { ...p.scenes, [sceneId]: next };
        const project: Project = { ...p, scenes };
        return { ...s, project };
      });
      await api.invoke(ipcChannels.project.updateScene, { sceneId, patch });
    };

    const updateConnections = async (sceneId: SceneId, connections: SceneId[]) => {
      setState((s) => {
        const p = s.project;
        if (!p) return s;
        const scene = p.scenes[sceneId];
        if (!scene) return s;
        const next: Scene = { ...scene, connections };
        const scenes = { ...p.scenes, [sceneId]: next };
        const project: Project = { ...p, scenes };
        return { ...s, project };
      });
      await api.invoke(ipcChannels.project.updateConnections, { sceneId, connections });
    };

    const importMediaToScene = async (sceneId: SceneId) => {
      const res = await api.invoke(ipcChannels.project.importMedia, { sceneId });
      setState((s) => ({ ...s, project: res.project }));
      await refreshProjects();
    };

    const importMediaToSceneFromPaths = async (sceneId: SceneId, filePaths: string[]) => {
      if (filePaths.length === 0) return;
      const res = await api.invoke(ipcChannels.project.importMedia, { sceneId, filePaths });
      if (res.imported.length === 0) return;
      setState((s) => ({ ...s, project: res.project }));
      await refreshProjects();
    };

    const importScenePreview = async (sceneId: SceneId) => {
      const res = await api.invoke(ipcChannels.project.importScenePreview, { sceneId });
      setState((s) => ({ ...s, project: res.project }));
      if (res.assetId !== null && res.background) {
        setState((s) => ({
          ...s,
          scenePreviewImports: {
            ...s.scenePreviewImports,
            [sceneId]: { assetId: res.assetId, phase: 'queued' },
          },
        }));
      }
      await refreshProjects();
      return { assetId: res.assetId, background: res.background };
    };

    const importScenePreviewFromPath = async (sceneId: SceneId, filePath: string) => {
      const res = await api.invoke(ipcChannels.project.importScenePreview, { sceneId, filePath });
      setState((s) => ({ ...s, project: res.project }));
      if (res.assetId !== null && res.background) {
        setState((s) => ({
          ...s,
          scenePreviewImports: {
            ...s.scenePreviewImports,
            [sceneId]: { assetId: res.assetId, phase: 'queued' },
          },
        }));
      }
      await refreshProjects();
      return { assetId: res.assetId, background: res.background };
    };

    const clearScenePreview = async (sceneId: SceneId) => {
      const res = await api.invoke(ipcChannels.project.clearScenePreview, { sceneId });
      setState((s) => ({ ...s, project: res.project }));
      await refreshProjects();
    };

    const updateSceneGraphNodePosition = async (nodeId: GraphNodeId, x: number, y: number) => {
      setState((s) => {
        const p = s.project;
        if (!p) return s;
        return {
          ...s,
          project: {
            ...p,
            sceneGraphNodes: p.sceneGraphNodes.map((n) => (n.id === nodeId ? { ...n, x, y } : n)),
          },
        };
      });
      const res = await api.invoke(ipcChannels.project.updateSceneGraphNodePosition, { nodeId, x, y });
      setState((s) => ({ ...s, project: res.project }));
    };

    const addSceneGraphNode = async (sceneId: SceneId, x: number, y: number) => {
      const res = await api.invoke(ipcChannels.project.addSceneGraphNode, { sceneId, x, y });
      setState((s) => ({ ...s, project: res.project }));
    };

    const removeSceneGraphNode = async (nodeId: GraphNodeId) => {
      const res = await api.invoke(ipcChannels.project.removeSceneGraphNode, { nodeId });
      setState((s) => ({ ...s, project: res.project }));
    };

    const addSceneGraphEdge = async (sourceGraphNodeId: GraphNodeId, targetGraphNodeId: GraphNodeId) => {
      const res = await api.invoke(ipcChannels.project.addSceneGraphEdge, {
        sourceGraphNodeId,
        targetGraphNodeId,
      });
      setState((s) => ({ ...s, project: res.project }));
    };

    const removeSceneGraphEdge = async (edgeId: string) => {
      const res = await api.invoke(ipcChannels.project.removeSceneGraphEdge, { edgeId });
      setState((s) => ({ ...s, project: res.project }));
    };

    const setSceneGraphNodeStart = async (graphNodeId: GraphNodeId | null) => {
      const res = await api.invoke(ipcChannels.project.setSceneGraphNodeStart, { graphNodeId });
      setState((s) => ({ ...s, project: res.project }));
    };

    const setSceneGraphNodeSideStoryStart = async (graphNodeId: GraphNodeId) => {
      const res = await api.invoke(ipcChannels.project.setSceneGraphNodeSideStoryStart, { graphNodeId });
      setState((s) => ({ ...s, project: res.project }));
    };

    const updateSideStoryLineTitle = async (graphNodeId: GraphNodeId, title: string) => {
      const res = await api.invoke(ipcChannels.project.updateSideStoryLineTitle, { graphNodeId, title });
      setState((s) => ({ ...s, project: res.project }));
    };

    const deleteScene = async (sceneId: SceneId) => {
      const res = await api.invoke(ipcChannels.project.deleteScene, { sceneId });
      setState((s) => ({
        ...s,
        project: res.project,
        selectedSceneId: res.project.currentSceneId ?? null,
      }));
      await refreshProjects();
    };

    const setSceneListOrder = async (sceneListOrder: SceneId[]) => {
      setState((s) => {
        const p = s.project;
        if (!p) return s;
        return { ...s, project: { ...p, sceneListOrder } };
      });
      const res = await api.invoke(ipcChannels.project.setSceneListOrder, { sceneListOrder });
      setState((s) => ({ ...s, project: res.project }));
    };

    const renameProject = async (name: string, fileBaseName: string) => {
      const res = await api.invoke(ipcChannels.project.rename, { name, fileBaseName });
      setState((s) => ({ ...s, project: res.project }));
      await refreshProjects();
    };

    const importProject = async () => {
      try {
        const res = await api.invoke(ipcChannels.project.importZip, {});
        if (res.canceled) return;
        setState((s) => ({
          ...s,
          project: res.project,
          selectedSceneId: res.project.currentSceneId,
        }));
        await refreshProjects();
      } finally {
        setState((s) => ({ ...s, zipProgress: null }));
      }
    };

    const importProjectFromPath = async (filePath: string) => {
      try {
        const res = await api.invoke(ipcChannels.project.importZipFromPath, { filePath });
        setState((s) => ({
          ...s,
          project: res.project,
          selectedSceneId: res.project.currentSceneId,
        }));
        await refreshProjects();
      } finally {
        setState((s) => ({ ...s, zipProgress: null }));
      }
    };

    const pickFoundrySource = async (mode: 'folder' | 'archive') => {
      return api.invoke(ipcChannels.project.pickFoundrySource, { mode });
    };

    const importFoundryProject = async (sourcePath: string) => {
      try {
        const res = await api.invoke(ipcChannels.project.importFoundry, { sourcePath });
        setState((s) => ({
          ...s,
          project: res.project,
          selectedSceneId: res.project.currentSceneId,
        }));
        await refreshProjects();
      } finally {
        setState((s) => ({ ...s, zipProgress: null }));
      }
    };

    const peekImportZip = async (labels: StorylineLabels, targetHasMainStart: boolean) => {
      return api.invoke(ipcChannels.project.peekImportZip, { labels, targetHasMainStart });
    };

    const pickImportZipFile = async () => {
      return api.invoke(ipcChannels.project.pickImportZipFile, {});
    };

    const peekImportZipPath = async (
      filePath: string,
      labels: StorylineLabels,
      targetHasMainStart: boolean,
    ) => {
      return api.invoke(ipcChannels.project.peekImportZipPath, { filePath, labels, targetHasMainStart });
    };

    const peekImportFromProject = async (
      sourceProjectId: ProjectId,
      labels: StorylineLabels,
      targetHasMainStart: boolean,
    ) => {
      return api.invoke(ipcChannels.project.peekImportFromProject, {
        sourceProjectId,
        labels,
        targetHasMainStart,
      });
    };

    const mergeImportZip = async (
      filePath: string,
      storylineSelections: StorylineSelection[],
      sceneResolutions: SceneImportResolution[],
      npcResolutions?: NpcImportResolution[],
    ) => {
      const res = await api.invoke(ipcChannels.project.mergeImportZip, {
        filePath,
        storylineSelections,
        sceneResolutions,
        ...(npcResolutions ? { npcResolutions } : {}),
      });
      setState((s) => ({
        ...s,
        project: res.project,
        selectedSceneId: res.project.currentSceneId ?? s.selectedSceneId,
      }));
      return res;
    };

    const mergeImportFromProject = async (
      sourceProjectId: ProjectId,
      storylineSelections: StorylineSelection[],
      sceneResolutions: SceneImportResolution[],
      npcResolutions?: NpcImportResolution[],
    ) => {
      const res = await api.invoke(ipcChannels.project.mergeImportFromProject, {
        sourceProjectId,
        storylineSelections,
        sceneResolutions,
        ...(npcResolutions ? { npcResolutions } : {}),
      });
      setState((s) => ({
        ...s,
        project: res.project,
        selectedSceneId: res.project.currentSceneId ?? s.selectedSceneId,
      }));
      return res;
    };

    const getProjectStorylines = async (projectId: ProjectId, labels: StorylineLabels) => {
      const res = await api.invoke(ipcChannels.project.getProjectStorylines, { projectId, labels });
      return {
        storylines: Array.isArray(res?.storylines) ? res.storylines : [],
        npcs: Array.isArray(res?.npcs) ? res.npcs : [],
      };
    };

    const exportProject = async (
      projectId: ProjectId,
      storylineSelections: StorylineSelection[],
      npcIds: string[],
      labels: StorylineLabels,
    ) => {
      try {
        const res = await api.invoke(ipcChannels.project.exportZip, {
          projectId,
          storylineSelections,
          npcIds,
          labels,
        });
        if (res.canceled) return;
      } finally {
        setState((s) => ({ ...s, zipProgress: null }));
      }
    };

    const deleteProject = async (projectId: ProjectId) => {
      projectDataEpochRef.current += 1;
      await api.invoke(ipcChannels.project.deleteProject, { projectId });
      const listRes = await api.invoke(ipcChannels.project.list, {});
      const res = await api.invoke(ipcChannels.project.get, {});
      setState((s) => ({
        ...s,
        projects: listRes.projects,
        project: res.project,
        selectedSceneId: res.project?.currentSceneId ?? null,
      }));
    };

    return {
      refreshProjects,
      createProject,
      openProject,
      closeProject,
      createScene,
      createScenesFromMediaPaths,
      selectScene,
      importCampaignAudio,
      importCampaignAudioFromPaths,
      updateCampaignAudios,
      upsertMaterial,
      deleteMaterial,
      setMaterialsOrder,
      setMaterialRotation,
      setMaterialLegend,
      pickMaterialImage,
      updateScene,
      updateConnections,
      importMediaToScene,
      importMediaToSceneFromPaths,
      importScenePreview,
      importScenePreviewFromPath,
      clearScenePreview,
      updateSceneGraphNodePosition,
      addSceneGraphNode,
      removeSceneGraphNode,
      addSceneGraphEdge,
      removeSceneGraphEdge,
      setSceneGraphNodeStart,
      setSceneGraphNodeSideStoryStart,
      updateSideStoryLineTitle,
      deleteScene,
      setSceneListOrder,
      renameProject,
      importProject,
      importProjectFromPath,
      pickFoundrySource,
      importFoundryProject,
      peekImportZip,
      pickImportZipFile,
      peekImportZipPath,
      peekImportFromProject,
      mergeImportZip,
      mergeImportFromProject,
      getProjectStorylines,
      exportProject,
      deleteProject,
    };
  }, [api]);

  useEffect(() => {
    const epoch = ++projectDataEpochRef.current;
    void (async () => {
      try {
        const listRes = await api.invoke(ipcChannels.project.list, {});
        if (projectDataEpochRef.current !== epoch) return;
        if (!licenseActive) {
          setState((s) => ({
            ...s,
            projects: listRes.projects,
            project: null,
            selectedSceneId: null,
          }));
          return;
        }
        setState((s) => ({ ...s, projects: listRes.projects }));
        const res = await api.invoke(ipcChannels.project.get, {});
        if (projectDataEpochRef.current !== epoch) return;
        setState((s) => ({
          ...s,
          project: res.project,
          // Восстановление открытого проекта — без автовыбора сцены в редакторе.
          selectedSceneId: null,
        }));
      } catch {
        if (projectDataEpochRef.current !== epoch) return;
        if (!licenseActive) {
          setState((s) => ({ ...s, project: null, selectedSceneId: null }));
        }
      }
    })();
  }, [licenseActive, api]);

  return [state, actions] as const;
}
