import type {
  ExportedStorylineRef,
  GraphNodeId,
  Project,
  ProjectNpc,
  ProjectNpcGroup,
  Scene,
  SceneGraphEdge,
  SceneGraphNode,
  SceneId,
} from '../types';
import type { AssetId, NpcGroupId, ProjectId, TokenId } from '../types/ids';
import {
  asAssetId,
  asGraphNodeId,
  asMaterialId,
  asNpcGroupId,
  asNpcId,
  asNpcRelationId,
  asProjectId,
  asSceneId,
  asSceneNpcTokenId,
  asTokenId,
} from '../types/ids';

import {
  getConnectedComponent,
  getSideStoryComponentNodeIds,
  listSideStoryStarts,
} from './sceneGraphLineage';
import { reconcileSceneListOrder } from './sceneListOrder';

export type StorylineKind = 'main' | 'side';

/** Выбор сюжетной линии для экспорта/импорта. */
export type StorylineSelection = { kind: 'main' } | { kind: 'side'; startGraphNodeId: GraphNodeId };

export type StorylineListItem = {
  selection: StorylineSelection;
  label: string;
  disabled?: boolean;
  disabledReason?: string;
};

export type SceneImportResolution =
  | { sourceSceneId: SceneId; mode: 'create' }
  | { sourceSceneId: SceneId; mode: 'use'; targetSceneId: SceneId };

export type SceneTitleConflict = {
  sourceSceneId: SceneId;
  sourceTitle: string;
  matches: { sceneId: SceneId; title: string }[];
};

export type NpcImportResolution =
  | { sourceNpcId: string; mode: 'create' }
  | { sourceNpcId: string; mode: 'use'; targetNpcId: string };

export type NpcNameConflict = {
  sourceNpcId: string;
  sourceName: string;
  matches: { npcId: string; name: string }[];
};

export type StorylineImportMergeReport = {
  storylinesImported: number;
  scenesCreated: number;
  scenesReused: number;
  graphNodesAdded: number;
  edgesAdded: number;
  assetsCopied: number;
  assetsReused: number;
  npcsCreated: number;
  npcsReused: number;
  renamedSideTitles: string[];
};

export type StorylineLabels = {
  main: string;
  untitled: string;
};

function generateId(): string {
  return `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
}

export function storylineSelectionKey(sel: StorylineSelection): string {
  return sel.kind === 'main' ? 'main' : `side:${sel.startGraphNodeId}`;
}

export function storylineSelectionsEqual(a: StorylineSelection, b: StorylineSelection): boolean {
  return storylineSelectionKey(a) === storylineSelectionKey(b);
}

export function sideStoryDisplayLabel(
  node: SceneGraphNode,
  scenes: Record<SceneId, Scene>,
  untitledLabel: string,
): string {
  const custom = node.sideStoryLineTitle.trim();
  if (custom) return custom;
  const scene = scenes[node.sceneId];
  const sceneTitle = scene?.title.trim();
  if (sceneTitle) return sceneTitle;
  return untitledLabel;
}

function findMainStartNode(nodes: SceneGraphNode[]): SceneGraphNode | null {
  return nodes.find((n) => n.isStartScene) ?? null;
}

export function collectStorylineGraphNodeIds(
  project: Project,
  selection: StorylineSelection,
): Set<GraphNodeId> {
  const { sceneGraphNodes, sceneGraphEdges } = project;
  if (selection.kind === 'main') {
    const mainStart = findMainStartNode(sceneGraphNodes);
    if (!mainStart) return new Set();
    return getConnectedComponent(sceneGraphNodes, sceneGraphEdges, mainStart.id);
  }
  return getSideStoryComponentNodeIds(sceneGraphNodes, sceneGraphEdges, selection.startGraphNodeId);
}

export function collectSelectionsGraphNodeIds(
  project: Project,
  selections: StorylineSelection[],
): Set<GraphNodeId> {
  const out = new Set<GraphNodeId>();
  for (const sel of selections) {
    for (const id of collectStorylineGraphNodeIds(project, sel)) {
      out.add(id);
    }
  }
  return out;
}

export function listExportableStorylines(project: Project, labels: StorylineLabels): StorylineListItem[] {
  const items: StorylineListItem[] = [];
  if (findMainStartNode(project.sceneGraphNodes)) {
    items.push({ selection: { kind: 'main' }, label: labels.main });
  }
  for (const gn of listSideStoryStarts(project.sceneGraphNodes)) {
    items.push({
      selection: { kind: 'side', startGraphNodeId: gn.id },
      label: sideStoryDisplayLabel(gn, project.scenes, labels.untitled),
    });
  }
  return items;
}

export function listImportableStorylines(
  source: Project,
  labels: StorylineLabels,
  targetHasMainStart: boolean,
): StorylineListItem[] {
  const fromManifest = source.exportedStorylines;
  if (fromManifest && fromManifest.length > 0) {
    return fromManifest.map((entry) => {
      const selection: StorylineSelection =
        entry.kind === 'main'
          ? { kind: 'main' }
          : { kind: 'side', startGraphNodeId: entry.startGraphNodeId! };
      const item: StorylineListItem = { selection, label: entry.label };
      if (entry.kind === 'main' && targetHasMainStart) {
        item.disabled = true;
        item.disabledReason = 'main_exists';
      }
      return item;
    });
  }
  return listExportableStorylines(source, labels).map((item) => {
    if (item.selection.kind === 'main' && targetHasMainStart) {
      return { ...item, disabled: true, disabledReason: 'main_exists' };
    }
    return item;
  });
}

function recomputeOutgoing(nodes: SceneGraphNode[], edges: SceneGraphEdge[]): Map<SceneId, Set<SceneId>> {
  const gnMap = new Map(nodes.map((n) => [n.id, n]));
  const outgoing = new Map<SceneId, Set<SceneId>>();
  for (const e of edges) {
    const a = gnMap.get(e.sourceGraphNodeId);
    const b = gnMap.get(e.targetGraphNodeId);
    if (!a || !b || a.sceneId === b.sceneId) continue;
    let set = outgoing.get(a.sceneId);
    if (!set) {
      set = new Set();
      outgoing.set(a.sceneId, set);
    }
    set.add(b.sceneId);
  }
  return outgoing;
}

function applyConnectionSets(
  scenes: Record<SceneId, Scene>,
  outgoing: Map<SceneId, Set<SceneId>>,
): Record<SceneId, Scene> {
  const next: Record<SceneId, Scene> = { ...scenes };
  for (const sid of Object.keys(next) as SceneId[]) {
    const prev = next[sid];
    if (!prev) continue;
    const c = outgoing.get(sid);
    next[sid] = { ...prev, connections: c ? [...c] : [] };
  }
  return next;
}

function selectionToExportedRef(
  project: Project,
  selection: StorylineSelection,
  labels: StorylineLabels,
): ExportedStorylineRef {
  if (selection.kind === 'main') {
    return { kind: 'main', startGraphNodeId: null, label: labels.main };
  }
  const start = project.sceneGraphNodes.find((n) => n.id === selection.startGraphNodeId);
  return {
    kind: 'side',
    startGraphNodeId: selection.startGraphNodeId,
    label: start ? sideStoryDisplayLabel(start, project.scenes, labels.untitled) : labels.untitled,
  };
}

export function collectSceneIdsForSelections(project: Project, selections: StorylineSelection[]): SceneId[] {
  const nodeIds = collectSelectionsGraphNodeIds(project, selections);
  const sceneIds = new Set<SceneId>();
  for (const gn of project.sceneGraphNodes) {
    if (nodeIds.has(gn.id)) sceneIds.add(gn.sceneId);
  }
  return [...sceneIds];
}

export function buildPartialExportProject(
  source: Project,
  selections: StorylineSelection[],
  opts: {
    newProjectId: ProjectId;
    exportTitle: string;
    labels: StorylineLabels;
    /** Явный список НПС; пустой — без НПС в архиве. */
    npcIds?: readonly string[];
  },
): Project {
  const nodeIds = collectSelectionsGraphNodeIds(source, selections);
  const sceneIds = new Set(collectSceneIdsForSelections(source, selections));

  const sceneGraphNodes = source.sceneGraphNodes.filter((n) => nodeIds.has(n.id)).map((n) => ({ ...n }));

  const sceneGraphEdges = source.sceneGraphEdges.filter(
    (e) => nodeIds.has(e.sourceGraphNodeId) && nodeIds.has(e.targetGraphNodeId),
  );

  const scenes: Record<SceneId, Scene> = {};
  for (const sid of sceneIds) {
    const sc = source.scenes[sid];
    if (sc) scenes[sid] = { ...sc };
  }

  let draft: Project = {
    ...source,
    id: opts.newProjectId,
    meta: {
      ...source.meta,
      name: opts.exportTitle,
      updatedAt: new Date().toISOString(),
    },
    scenes,
    sceneListOrder: reconcileSceneListOrder(scenes, source.sceneListOrder),
    sceneGraphNodes,
    sceneGraphEdges,
    currentSceneId: null,
    currentGraphNodeId: null,
    exportedStorylines: selections.map((s) => selectionToExportedRef(source, s, opts.labels)),
  };

  const outgoing = recomputeOutgoing(draft.sceneGraphNodes, draft.sceneGraphEdges);
  draft = { ...draft, scenes: applyConnectionSets(draft.scenes, outgoing) };

  const exportedNpcs = selectNpcsByIds(source, opts.npcIds ?? []);
  const exportedNpcIds = new Set(exportedNpcs.map((n) => n.id));
  const exportedRelations = (source.npcRelations ?? []).filter(
    (r) => exportedNpcIds.has(r.sourceNpcId) && exportedNpcIds.has(r.targetNpcId),
  );
  const exportedGroupIds = collectNpcGroupIdsForNpcs(source.npcGroups ?? [], exportedNpcs);
  const exportedGroups = (source.npcGroups ?? []).filter((g) => exportedGroupIds.has(g.id));

  draft = {
    ...draft,
    npcs: exportedNpcs.map((n) => ({ ...n })),
    npcGroups: exportedGroups.map((g) => ({ ...g })),
    npcRelations: exportedRelations.map((r) => ({ ...r })),
    scenes: Object.fromEntries(
      Object.entries(draft.scenes).map(([sid, sc]) => [
        sid,
        {
          ...sc,
          npcTokens: (sc.npcTokens ?? []).filter((t) => exportedNpcIds.has(t.npcId)),
        },
      ]),
    ) as Project['scenes'],
  };

  const assetIds = collectReferencedAssetIdsForProject(draft);
  const assets: Project['assets'] = {} as Project['assets'];
  for (const id of assetIds) {
    const a = source.assets[id];
    if (a) assets[id] = { ...a };
  }

  const mainStart = findMainStartNode(draft.sceneGraphNodes);
  const firstSide = listSideStoryStarts(draft.sceneGraphNodes)[0];
  return {
    ...draft,
    assets,
    campaignAudios: source.campaignAudios.map((a) => ({ ...a })),
    materials: (source.materials ?? []).map((m) => ({ ...m })),
    currentSceneId: mainStart?.sceneId ?? firstSide?.sceneId ?? null,
    currentGraphNodeId: mainStart?.id ?? firstSide?.id ?? null,
  };
}

/** НПС по явному списку id (порядок — как в проекте). */
export function selectNpcsByIds(project: Project, npcIds: readonly string[]): ProjectNpc[] {
  if (npcIds.length === 0) return [];
  const wanted = new Set(npcIds);
  return (project.npcs ?? []).filter((n) => wanted.has(n.id));
}

/** НПС из экспортного пакета — импортируем всех, кто в нём лежит. */
export function listExportedNpcsFromBundle(source: Project): ProjectNpc[] {
  return [...(source.npcs ?? [])];
}

function collectNpcGroupIdsForNpcs(groups: ProjectNpcGroup[], npcs: ProjectNpc[]): Set<NpcGroupId> {
  const needed = new Set<NpcGroupId>();
  for (const n of npcs) {
    if (n.groupId) needed.add(n.groupId);
  }
  let changed = true;
  while (changed) {
    changed = false;
    for (const g of groups) {
      if (needed.has(g.id) && g.parentId && !needed.has(g.parentId)) {
        needed.add(g.parentId);
        changed = true;
      }
    }
  }
  return needed;
}

export function findNpcNameConflicts(
  target: Project,
  source: Project,
  sourceNpcIds: string[],
): NpcNameConflict[] {
  const targetByName = new Map<string, { npcId: string; name: string }[]>();
  for (const n of target.npcs ?? []) {
    const key = n.name.trim().toLowerCase();
    if (!key) continue;
    const list = targetByName.get(key) ?? [];
    list.push({ npcId: n.id, name: n.name });
    targetByName.set(key, list);
  }
  const conflicts: NpcNameConflict[] = [];
  const seen = new Set<string>();
  for (const id of sourceNpcIds) {
    if (seen.has(id)) continue;
    seen.add(id);
    const src = (source.npcs ?? []).find((n) => n.id === id);
    if (!src) continue;
    const key = src.name.trim().toLowerCase();
    if (!key) continue;
    const matches = targetByName.get(key);
    if (matches && matches.length > 0) {
      conflicts.push({ sourceNpcId: src.id, sourceName: src.name, matches });
    }
  }
  return conflicts;
}

function collectReferencedAssetIdsForProject(p: Project): Set<AssetId> {
  const refs = new Set<AssetId>();
  for (const sc of Object.values(p.scenes)) {
    if (sc.previewAssetId) refs.add(sc.previewAssetId);
    if (sc.previewThumbAssetId) refs.add(sc.previewThumbAssetId);
    for (const vid of sc.media.videos) refs.add(vid);
    for (const au of sc.media.audios) refs.add(au.assetId);
  }
  for (const au of p.campaignAudios) refs.add(au.assetId);
  for (const m of p.materials ?? []) refs.add(m.assetId);
  for (const n of p.npcs ?? []) refs.add(n.avatarAssetId);
  return refs;
}

export function findSceneTitleConflicts(
  target: Project,
  source: Project,
  sourceSceneIds: SceneId[],
): SceneTitleConflict[] {
  const targetByTitle = new Map<string, { sceneId: SceneId; title: string }[]>();
  for (const sc of Object.values(target.scenes)) {
    const key = sc.title.trim().toLowerCase();
    if (!key) continue;
    const list = targetByTitle.get(key) ?? [];
    list.push({ sceneId: sc.id, title: sc.title });
    targetByTitle.set(key, list);
  }

  const conflicts: SceneTitleConflict[] = [];
  const seen = new Set<SceneId>();
  for (const sid of sourceSceneIds) {
    if (seen.has(sid)) continue;
    seen.add(sid);
    const src = source.scenes[sid];
    if (!src) continue;
    const key = src.title.trim().toLowerCase();
    if (!key) continue;
    const matches = targetByTitle.get(key);
    if (matches && matches.length > 0) {
      conflicts.push({ sourceSceneId: sid, sourceTitle: src.title, matches });
    }
  }
  return conflicts;
}

function remapSceneAssetRefs(scene: Scene, assetMap: Map<AssetId, AssetId>): Scene {
  const mapId = (id: AssetId | null): AssetId | null => (id ? (assetMap.get(id) ?? id) : null);
  return {
    ...scene,
    previewAssetId: mapId(scene.previewAssetId),
    previewThumbAssetId: mapId(scene.previewThumbAssetId),
    media: {
      videos: scene.media.videos.map((id) => assetMap.get(id) ?? id),
      audios: scene.media.audios.map((a) => ({
        ...a,
        assetId: assetMap.get(a.assetId) ?? a.assetId,
      })),
    },
  };
}

function uniqueSideTitle(existing: Set<string>, desired: string): string {
  const base = desired.trim() || 'Побочная';
  if (!existing.has(base.toLowerCase())) {
    existing.add(base.toLowerCase());
    return base;
  }
  let n = 2;
  while (existing.has(`${base} (${String(n)})`.toLowerCase())) n += 1;
  const next = `${base} (${String(n)})`;
  existing.add(next.toLowerCase());
  return next;
}

export function mergeStorylinesIntoProject(
  target: Project,
  source: Project,
  selections: StorylineSelection[],
  sceneResolutions: SceneImportResolution[],
  opts: { graphOffsetX: number; npcResolutions?: NpcImportResolution[] },
): {
  project: Project;
  report: StorylineImportMergeReport;
  assetCopies: { fromId: AssetId; toId: AssetId }[];
} {
  const resolutionBySource = new Map(sceneResolutions.map((r) => [r.sourceSceneId, r]));
  const npcResolutionBySource = new Map((opts.npcResolutions ?? []).map((r) => [r.sourceNpcId, r]));
  const nodeIds = collectSelectionsGraphNodeIds(source, selections);
  const sourceSceneIds = collectSceneIdsForSelections(source, selections);

  const neededAssetIds = new Set<AssetId>();
  for (const sid of sourceSceneIds) {
    const sc = source.scenes[sid];
    if (!sc) continue;
    const res = resolutionBySource.get(sid);
    if (res?.mode === 'use') continue;
    if (sc.previewAssetId) neededAssetIds.add(sc.previewAssetId);
    if (sc.previewThumbAssetId) neededAssetIds.add(sc.previewThumbAssetId);
    for (const vid of sc.media.videos) neededAssetIds.add(vid);
    for (const au of sc.media.audios) neededAssetIds.add(au.assetId);
  }
  for (const au of source.campaignAudios) neededAssetIds.add(au.assetId);
  for (const m of source.materials ?? []) neededAssetIds.add(m.assetId);

  const exportedNpcs = listExportedNpcsFromBundle(source);
  for (const n of exportedNpcs) {
    const res = npcResolutionBySource.get(n.id);
    if (res?.mode === 'use') continue;
    neededAssetIds.add(n.avatarAssetId);
  }

  const assetMap = new Map<AssetId, AssetId>();
  const targetSha = new Map<string, AssetId>();
  for (const a of Object.values(target.assets)) {
    targetSha.set(a.sha256, a.id);
  }
  let assetsCopied = 0;
  let assetsReused = 0;
  const assetCopies: { fromId: AssetId; toId: AssetId }[] = [];
  for (const id of neededAssetIds) {
    const srcAsset = source.assets[id];
    if (!srcAsset) continue;
    const existing = targetSha.get(srcAsset.sha256);
    if (existing) {
      assetMap.set(id, existing);
      assetsReused += 1;
    } else {
      const newId = asAssetId(`a_${generateId()}`);
      assetMap.set(id, newId);
      targetSha.set(srcAsset.sha256, newId);
      assetCopies.push({ fromId: id, toId: newId });
      assetsCopied += 1;
    }
  }

  const sceneIdMap = new Map<SceneId, SceneId>();
  let scenesCreated = 0;
  let scenesReused = 0;
  const scenes: Record<SceneId, Scene> = { ...target.scenes };
  for (const sid of sourceSceneIds) {
    const res = resolutionBySource.get(sid);
    const srcScene = source.scenes[sid];
    if (!srcScene) continue;
    if (res?.mode === 'use') {
      sceneIdMap.set(sid, res.targetSceneId);
      scenesReused += 1;
    } else {
      const newId = asSceneId(`s_${generateId()}`);
      sceneIdMap.set(sid, newId);
      scenes[newId] = remapSceneAssetRefs({ ...srcScene, id: newId }, assetMap);
      scenesCreated += 1;
    }
  }

  const importedNodes = source.sceneGraphNodes.filter((n) => nodeIds.has(n.id));
  const minX = importedNodes.length > 0 ? Math.min(...importedNodes.map((n) => n.x)) : 0;
  const xShift = opts.graphOffsetX - minX;

  const graphNodeIdMap = new Map<GraphNodeId, GraphNodeId>();
  for (const gn of importedNodes) {
    graphNodeIdMap.set(gn.id, asGraphNodeId(`gn_${generateId()}`));
  }

  const existingSideTitles = new Set(
    target.sceneGraphNodes
      .filter((n) => n.isSideStoryStart)
      .map((n) => (n.sideStoryLineTitle.trim() || 'Побочная').toLowerCase()),
  );
  const renamedSideTitles: string[] = [];

  const importMain = selections.some((s) => s.kind === 'main');
  const targetHasMain = !!findMainStartNode(target.sceneGraphNodes);

  const newGraphNodes: SceneGraphNode[] = importedNodes.map((gn) => {
    const newId = graphNodeIdMap.get(gn.id)!;
    let sideStoryLineTitle = gn.sideStoryLineTitle;
    if (gn.isSideStoryStart) {
      const desired = sideStoryDisplayLabel(gn, source.scenes, 'Без названия');
      const resolved = uniqueSideTitle(existingSideTitles, desired);
      if (resolved !== desired) renamedSideTitles.push(resolved);
      sideStoryLineTitle = resolved;
    }
    return {
      ...gn,
      id: newId,
      sceneId: sceneIdMap.get(gn.sceneId) ?? gn.sceneId,
      x: gn.x + xShift,
      y: gn.y,
      isStartScene: importMain && !targetHasMain ? gn.isStartScene : false,
      sideStoryLineTitle,
    };
  });

  const newEdges: SceneGraphEdge[] = source.sceneGraphEdges
    .filter((e) => nodeIds.has(e.sourceGraphNodeId) && nodeIds.has(e.targetGraphNodeId))
    .map((e) => ({
      id: `e_${generateId()}`,
      sourceGraphNodeId: graphNodeIdMap.get(e.sourceGraphNodeId)!,
      targetGraphNodeId: graphNodeIdMap.get(e.targetGraphNodeId)!,
    }));

  const assets: Project['assets'] = { ...target.assets };
  for (const [srcId, tgtId] of assetMap) {
    if (assets[tgtId]) continue;
    const srcAsset = source.assets[srcId];
    if (srcAsset) {
      assets[tgtId] = { ...srcAsset, id: tgtId };
    }
  }

  const campaignAudios = [...target.campaignAudios];
  const campaignAssetIds = new Set(campaignAudios.map((a) => a.assetId));
  for (const au of source.campaignAudios) {
    const mapped = assetMap.get(au.assetId) ?? au.assetId;
    if (!campaignAssetIds.has(mapped)) {
      campaignAudios.push({ ...au, assetId: mapped });
      campaignAssetIds.add(mapped);
    }
  }

  const materials = [...(target.materials ?? [])];
  const materialNameKeys = new Set(materials.map((m) => m.name.trim().toLowerCase()));
  const materialAssetIds = new Set(materials.map((m) => m.assetId));
  for (const m of source.materials ?? []) {
    const mapped = assetMap.get(m.assetId) ?? m.assetId;
    if (materialAssetIds.has(mapped)) continue;
    let name = m.name.trim();
    const baseKey = name.toLowerCase();
    if (materialNameKeys.has(baseKey)) {
      let n = 2;
      while (materialNameKeys.has(`${baseKey} (${String(n)})`)) n += 1;
      name = `${name} (${String(n)})`;
    }
    materials.push({
      id: asMaterialId(`mat_${generateId()}`),
      name,
      assetId: mapped,
      rotationDeg: m.rotationDeg ?? 0,
    });
    materialNameKeys.add(name.toLowerCase());
    materialAssetIds.add(mapped);
  }

  // Группы только для создаваемых НПС выбранных линий.
  const npcsToCreate = exportedNpcs.filter((n) => npcResolutionBySource.get(n.id)?.mode !== 'use');
  const neededGroupIds = collectNpcGroupIdsForNpcs(source.npcGroups ?? [], npcsToCreate);
  const npcGroups = [...(target.npcGroups ?? [])];
  const groupIdMap = new Map<string, string>();
  const orderedSourceGroups = topologicalNpcGroups(source.npcGroups ?? []).filter((g) =>
    neededGroupIds.has(g.id),
  );
  for (const g of orderedSourceGroups) {
    const mappedParent = g.parentId
      ? (asNpcGroupId(groupIdMap.get(g.parentId) ?? g.parentId) as NpcGroupId | null)
      : null;
    const parentKey = mappedParent;
    const siblings = npcGroups.filter((x) => x.parentId === parentKey);
    const nameKey = g.name.trim().toLowerCase();
    const existing = siblings.find((x) => x.name.trim().toLowerCase() === nameKey);
    if (existing) {
      groupIdMap.set(g.id, existing.id);
      continue;
    }
    let name = g.name.trim();
    const siblingKeys = new Set(siblings.map((x) => x.name.trim().toLowerCase()));
    if (siblingKeys.has(name.toLowerCase())) {
      let i = 2;
      while (siblingKeys.has(`${name.toLowerCase()} (${String(i)})`)) i += 1;
      name = `${name} (${String(i)})`;
    }
    const newId = asNpcGroupId(`ng_${generateId()}`);
    groupIdMap.set(g.id, newId);
    npcGroups.push({
      id: newId,
      name,
      color: g.color,
      parentId: mappedParent,
    });
  }

  const npcs = [...(target.npcs ?? [])];
  const npcNameKeys = new Set(npcs.map((n) => n.name.trim().toLowerCase()));
  const npcIdMap = new Map<string, string>();
  let npcsCreated = 0;
  let npcsReused = 0;
  for (const n of exportedNpcs) {
    const mappedAvatar = assetMap.get(n.avatarAssetId) ?? n.avatarAssetId;
    const res = npcResolutionBySource.get(n.id);
    if (res?.mode === 'use') {
      npcIdMap.set(n.id, res.targetNpcId);
      npcsReused += 1;
      continue;
    }
    // default create (also when no resolution entry)
    let name = n.name.trim();
    const baseKey = name.toLowerCase();
    if (npcNameKeys.has(baseKey)) {
      let i = 2;
      while (npcNameKeys.has(`${baseKey} (${String(i)})`)) i += 1;
      name = `${name} (${String(i)})`;
    }
    const newId = asNpcId(`npc_${generateId()}`);
    npcIdMap.set(n.id, newId);
    const mappedGroupId = n.groupId
      ? (asNpcGroupId(groupIdMap.get(n.groupId) ?? n.groupId) as typeof n.groupId)
      : null;
    npcs.push({
      id: newId,
      name,
      avatarAssetId: mappedAvatar,
      description: n.description ?? '',
      x: n.x,
      y: n.y,
      groupId: mappedGroupId && npcGroups.some((g) => g.id === mappedGroupId) ? mappedGroupId : null,
      ringColor: n.ringColor ?? '#c9a227',
      disposition: n.disposition === 'hostile' || n.disposition === 'friendly' ? n.disposition : 'neutral',
      imageOffset: n.imageOffset ?? { x: 0, y: 0 },
      imageScale: typeof n.imageScale === 'number' && Number.isFinite(n.imageScale) ? n.imageScale : 1,
    });
    npcNameKeys.add(name.toLowerCase());
    npcsCreated += 1;
  }

  // Remap npcTokens on newly created scenes to imported NPC ids.
  for (const sid of sourceSceneIds) {
    const res = resolutionBySource.get(sid);
    if (res?.mode === 'use') continue;
    const newSid = sceneIdMap.get(sid);
    if (!newSid) continue;
    const sc = scenes[newSid];
    if (!sc) continue;
    scenes[newSid] = {
      ...sc,
      npcTokens: (sc.npcTokens ?? [])
        .map((t) => {
          const mappedNpc = npcIdMap.get(t.npcId);
          if (!mappedNpc) return null;
          return {
            ...t,
            id: asSceneNpcTokenId(`snt_${generateId()}`),
            npcId: asNpcId(mappedNpc),
          };
        })
        .filter((t): t is NonNullable<typeof t> => Boolean(t)),
    };
  }

  const npcRelations = [...(target.npcRelations ?? [])];
  const exportedNpcIdSet = new Set(exportedNpcs.map((n) => n.id));
  for (const r of source.npcRelations ?? []) {
    if (!exportedNpcIdSet.has(r.sourceNpcId) || !exportedNpcIdSet.has(r.targetNpcId)) continue;
    const sourceId = npcIdMap.get(r.sourceNpcId);
    const targetId = npcIdMap.get(r.targetNpcId);
    if (!sourceId || !targetId) continue;
    if (!npcs.some((n) => n.id === sourceId) || !npcs.some((n) => n.id === targetId)) continue;
    const label = r.label.trim();
    if (!label) continue;
    const dup = npcRelations.some(
      (x) => x.label === label && x.sourceNpcId === sourceId && x.targetNpcId === targetId,
    );
    if (dup) continue;
    npcRelations.push({
      id: asNpcRelationId(`nrel_${generateId()}`),
      sourceNpcId: asNpcId(sourceId),
      targetNpcId: asNpcId(targetId),
      label,
    });
  }

  let merged: Project = {
    ...target,
    scenes,
    assets,
    campaignAudios,
    materials,
    npcs,
    npcGroups,
    npcRelations,
    sceneGraphNodes: [...target.sceneGraphNodes, ...newGraphNodes],
    sceneGraphEdges: [...target.sceneGraphEdges, ...newEdges],
  };

  const outgoing = recomputeOutgoing(merged.sceneGraphNodes, merged.sceneGraphEdges);
  merged = {
    ...merged,
    scenes: applyConnectionSets(merged.scenes, outgoing),
    sceneListOrder: reconcileSceneListOrder(scenes, target.sceneListOrder),
  };

  return {
    project: merged,
    report: {
      storylinesImported: selections.length,
      scenesCreated,
      scenesReused,
      graphNodesAdded: newGraphNodes.length,
      edgesAdded: newEdges.length,
      assetsCopied,
      assetsReused,
      npcsCreated,
      npcsReused,
      renamedSideTitles,
    },
    assetCopies,
  };
}

export function computeGraphImportOffsetX(target: Project, padding = 120): number {
  const maxX = target.sceneGraphNodes.reduce((m, n) => Math.max(m, n.x), 0);
  return maxX + padding;
}

function topologicalNpcGroups(groups: ProjectNpcGroup[]): ProjectNpcGroup[] {
  const byId = new Map(groups.map((g) => [g.id, g]));
  const out: ProjectNpcGroup[] = [];
  const seen = new Set<string>();
  const visit = (id: string): void => {
    if (seen.has(id)) return;
    seen.add(id);
    const g = byId.get(asNpcGroupId(id));
    if (!g) return;
    if (g.parentId && byId.has(g.parentId)) visit(g.parentId);
    out.push(g);
  };
  for (const g of groups) visit(g.id);
  return out;
}

export function newExportBundleProjectId(): ProjectId {
  return asProjectId(`p_${generateId()}`);
}

/** Id app-токенов, реально стоящих на сценах проекта (для экспорта с линией). */
export function collectTokenIdsFromProject(project: Project): TokenId[] {
  const ids = new Set<string>();
  for (const scene of Object.values(project.scenes)) {
    for (const t of scene.tokens ?? []) {
      if (t.tokenId) ids.add(t.tokenId);
    }
  }
  return [...ids].map((id) => asTokenId(id));
}

/** Переписать tokenId на сценах после импорта в app-пул. */
export function remapProjectSceneTokenIds(
  project: Project,
  remap: ReadonlyMap<string, string>,
): Project {
  if (remap.size === 0) return project;
  const scenes: Record<SceneId, Scene> = { ...project.scenes };
  for (const sid of Object.keys(scenes) as SceneId[]) {
    const scene = scenes[sid];
    if (!scene?.tokens?.length) continue;
    scenes[sid] = {
      ...scene,
      tokens: scene.tokens.map((t) => ({
        ...t,
        tokenId: asTokenId(remap.get(t.tokenId) ?? t.tokenId),
      })),
    };
  }
  return { ...project, scenes };
}
