import {
  Background,
  Handle,
  MiniMap,
  NodeResizer,
  Position,
  ReactFlow,
  ReactFlowProvider,
  type Edge,
  type Node,
  type NodeChange,
  type NodeProps,
  useNodesState,
  useReactFlow
} from "@xyflow/react";
import "@xyflow/react/dist/style.css";
import { FileText, Globe2, Layers3, LockKeyhole, Maximize2, Minus, PanelTop, Plus, RefreshCw, Save, Settings2 } from "lucide-react";
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
  fetchProjectScopeLayout,
  getPreviewSnapshotUrl,
  saveProjectScopeLayout,
  type ProjectScanSummary,
  type ScopeFlowLayout
} from "./api";

type ScanPage = ProjectScanSummary["pages"][number];
type ScanSection = ProjectScanSummary["siteSections"][number];
type ScopeType = ScanPage["scope"];

type ScopeNodeData = {
  description: string;
  disabled: boolean;
  headings: string[];
  label: string;
  onToggle?: (pageId: string, selected: boolean) => void;
  pageId?: string;
  path: string;
  previewMode: "frame" | "empty" | "none";
  selected: boolean;
  sectionId?: string;
  scope: ScopeType | "project" | "home_section" | "page_group";
  scopeLabel: string;
  status: "coverage" | "excluded" | "project";
  subtitle: string;
  previewUrl: string | null;
  previewKind: ScanPage["previewKind"] | ScanSection["previewKind"] | "project";
  previewNote: string | null;
  visibleInSite: boolean | null;
};

type ScopeFlowNode = Node<ScopeNodeData, "scopeNode">;

type Props = {
  isBusy: boolean;
  onApplyRecommendedScope: () => void;
  onClearSelection: () => void;
  onTogglePage: (pageId: string, selected: boolean) => void;
  projectId: string;
  projectName: string;
  scan: ProjectScanSummary;
};

const NODE_WIDTH = 1220;
const NODE_HEIGHT = 430;
const COMPACT_NODE_WIDTH = 460;
const COMPACT_NODE_HEIGHT = 248;
const SELECTED_NODE_WIDTH = 1320;
const SELECTED_NODE_HEIGHT = 450;
const X_GAP = 220;
const Y_GAP = 132;
const ROOT_WIDTH = 410;
const MAX_COLUMNS = 2;
const DEFAULT_SCOPE_VIEWPORT = { x: 40, y: 48, zoom: 0.42 };
const LAYOUT_STORAGE_VERSION = 1;
const TOOLBAR_MIN_SIZE = 34;
const TOOLBAR_MAX_SIZE = 87;
const TOOLBAR_GAP = 10;
const TOOLBAR_LENS_COUNT = 5;
const TOOLBAR_DOCK_BASE_SCALE = 2.25;
const TOOLBAR_DOCK_BASE_DISTANCE = 110;
const TOOLBAR_DOCK_BASE_NUDGE = 40;
const TOOLBAR_DOCK_NUDGE_TIGHTNESS = 0.82;
const TOOLBAR_DOCK_MAX_STEP = 1 / 240;
const TOOLBAR_DOCK_SPRING = {
  damping: 12,
  mass: 0.1,
  stiffness: 170
};
const SCOPE_FLOW_TOOLBAR_KEYS = ["save", "refresh", "zoom-in", "zoom-out", "fit-view"];

type DockMotion = {
  scale: number;
  scaleVelocity: number;
  shiftVelocity: number;
  shiftY: number;
  targetScale: number;
  targetShiftY: number;
};

type SavedScopeLayout = ScopeFlowLayout;

const scopePriority: ScopeType[] = ["indexable_page", "template_block", "admin_page", "technical_page"];

const scopeLabels: Record<ScopeType, string> = {
  admin_page: "админка",
  indexable_page: "посадочная",
  technical_page: "техническая",
  template_block: "шаблон"
};

const scopeGroupNames: Record<ScopeType, string> = {
  admin_page: "Админка",
  indexable_page: "Публичные страницы",
  technical_page: "Технический слой",
  template_block: "Секции главной"
};

function isHomePage(page: ScanPage) {
  return page.urlPath === "/" && /(^|\/)index\.html?$/i.test(page.sourcePath);
}

function getPageTitle(page: ScanPage) {
  if (page.scope === "template_block" && page.structureLabel) {
    return page.structureLabel;
  }

  if (isHomePage(page)) {
    return page.title || "Главная страница";
  }

  return page.title || page.urlPath || page.sourcePath;
}

function getPagePath(page: ScanPage) {
  if (page.scope === "template_block" && page.previewKind === "home_section") {
    return `${page.sourcePath} · секция главной`;
  }

  if (page.scope === "template_block" && page.visibleInSite === false) {
    return `${page.sourcePath} · выключено в home.json`;
  }

  if (isHomePage(page)) {
    return "index.html · главная страница";
  }

  return page.urlPath && page.urlPath !== page.sourcePath ? `${page.urlPath} · ${page.sourcePath}` : page.sourcePath;
}

function getPageSubtitle(page: ScanPage) {
  if (page.scope === "template_block" && page.visibleInSite === false) {
    return "Блок есть в модели проекта, но сейчас не рендерится на сайте.";
  }

  const h1 = page.headings.find((heading) => heading.level === 1)?.text?.trim();

  if (h1) {
    return h1;
  }

  const firstHeading = page.headings[0]?.text?.trim();

  return firstHeading || page.scopeReason;
}

function hashStableString(value: string) {
  let hash = 2166136261;

  for (let index = 0; index < value.length; index += 1) {
    hash ^= value.charCodeAt(index);
    hash = Math.imul(hash, 16777619);
  }

  return (hash >>> 0).toString(36);
}

function getScanContentKey(scan: ProjectScanSummary) {
  const contentContract = {
    assets: scan.assets
      .map((asset) => ({
        mediaType: asset.mediaType,
        size: asset.size,
        sourcePath: asset.sourcePath
      }))
      .sort((left, right) => left.sourcePath.localeCompare(right.sourcePath, "ru")),
    pages: scan.pages
      .map((page) => ({
        fingerprint: page.fingerprint,
        previewKind: page.previewKind,
        previewSourcePath: page.previewSourcePath,
        previewTargetSelector: page.previewTargetSelector,
        scope: page.scope,
        sourcePath: page.sourcePath,
        structureOrder: page.structureOrder,
        urlPath: page.urlPath,
        visibleInSite: page.visibleInSite
      }))
      .sort((left, right) => left.sourcePath.localeCompare(right.sourcePath, "ru")),
    sections: scan.siteSections
      .map((section) => ({
        enabled: section.enabled,
        order: section.order,
        previewSourcePath: section.previewSourcePath,
        previewTargetIndex: section.previewTargetIndex,
        previewTargetSelector: section.previewTargetSelector,
        sourcePath: section.sourcePath,
        template: section.template
      }))
      .sort((left, right) => `${left.order}:${left.sourcePath}`.localeCompare(`${right.order}:${right.sourcePath}`, "ru")),
    sourceId: scan.sourceId
  };

  return `content-${hashStableString(JSON.stringify(contentContract))}`;
}

function getScopeLayoutStorageKey(projectId: string, layoutKey: string) {
  return `seo-mode:project-scope-layout:${projectId}:${layoutKey}`;
}

function isFiniteNumber(value: unknown): value is number {
  return typeof value === "number" && Number.isFinite(value);
}

function stepToolbarSpring(value: number, velocity: number, target: number, dt: number) {
  let nextValue = value;
  let nextVelocity = velocity;
  const steps = Math.max(1, Math.ceil(dt / TOOLBAR_DOCK_MAX_STEP));
  const step = dt / steps;

  for (let index = 0; index < steps; index += 1) {
    const displacement = nextValue - target;
    const springForce = -TOOLBAR_DOCK_SPRING.stiffness * displacement;
    const dampingForce = -TOOLBAR_DOCK_SPRING.damping * nextVelocity;
    const acceleration = (springForce + dampingForce) / TOOLBAR_DOCK_SPRING.mass;

    nextVelocity += acceleration * step;
    nextValue += nextVelocity * step;
  }

  return [nextValue, nextVelocity] as const;
}

function readSavedScopeLayout(storageKey: string): SavedScopeLayout | null {
  try {
    const rawValue = window.localStorage.getItem(storageKey);
    if (!rawValue) return null;
    const parsed = JSON.parse(rawValue) as SavedScopeLayout;

    if (!parsed || parsed.version !== LAYOUT_STORAGE_VERSION || !parsed.nodes || typeof parsed.nodes !== "object") {
      return null;
    }

    return parsed;
  } catch {
    return null;
  }
}

function getNodeSize(node: ScopeFlowNode) {
  const style = node.style ?? {};
  const widthCandidate = (style as Record<string, unknown>).width ?? (node as unknown as { width?: number }).width;
  const heightCandidate = (style as Record<string, unknown>).height ?? (node as unknown as { height?: number }).height;
  const width = typeof widthCandidate === "string" ? Number.parseFloat(widthCandidate) : widthCandidate;
  const height = typeof heightCandidate === "string" ? Number.parseFloat(heightCandidate) : heightCandidate;

  return {
    height: isFiniteNumber(height) ? height : undefined,
    width: isFiniteNumber(width) ? width : undefined
  };
}

function applySavedScopeLayout(nodes: ScopeFlowNode[], savedLayout: SavedScopeLayout | null) {
  if (!savedLayout) return nodes;

  return nodes.map((node) => {
    const savedNode = savedLayout.nodes[node.id];

    if (!savedNode || !savedNode.position) {
      return node;
    }

    return {
      ...node,
      position: savedNode.position,
      style: {
        ...(node.style ?? {}),
        ...(isFiniteNumber(savedNode.height) ? { height: savedNode.height } : null),
        ...(isFiniteNumber(savedNode.width) ? { width: savedNode.width } : null)
      }
    };
  });
}

function createScopeLayout(nodes: ScopeFlowNode[], viewport: { x: number; y: number; zoom: number }) {
  const savedNodes: SavedScopeLayout["nodes"] = {};

  for (const node of nodes) {
    const size = getNodeSize(node);

    savedNodes[node.id] = {
      position: node.position,
      ...size
    };
  }

  return {
    nodes: savedNodes,
    version: LAYOUT_STORAGE_VERSION,
    viewport
  } satisfies SavedScopeLayout;
}

function writeSavedScopeLayout(storageKey: string, layout: SavedScopeLayout) {
  try {
    window.localStorage.setItem(storageKey, JSON.stringify(layout));
  } catch {
    // Server-side storage remains the source of truth when browser storage is unavailable.
  }
}

function saveScopeLayout(storageKey: string, nodes: ScopeFlowNode[], viewport: { x: number; y: number; zoom: number }) {
  const layout = createScopeLayout(nodes, viewport);

  writeSavedScopeLayout(storageKey, layout);

  return layout;
}

function persistScopeLayout(input: {
  layoutKey: string;
  nodes: ScopeFlowNode[];
  onLayoutSaved: (layout: SavedScopeLayout) => void;
  projectId: string;
  storageKey: string;
  viewport: { x: number; y: number; zoom: number };
}) {
  const layout = saveScopeLayout(input.storageKey, input.nodes, input.viewport);

  input.onLayoutSaved(layout);
  void saveProjectScopeLayout(input.projectId, input.layoutKey, layout).catch((error) => {
    console.warn("Failed to persist project scope layout.", error);
  });

  return layout;
}

function getPageSortKey(page: ScanPage) {
  return `${String(page.structureOrder ?? 9999).padStart(4, "0")}|${getPagePath(page)}`;
}

function getPagePreviewUrl(projectId: string, cacheVersion: string, page: ScanPage) {
  const previewSourcePath = page.previewSourcePath ?? (page.scope === "template_block" ? null : page.sourcePath);

  return previewSourcePath ? getPreviewSnapshotUrl(projectId, cacheVersion, previewSourcePath, page.previewTargetSelector) : null;
}

function getSectionPreviewUrl(projectId: string, cacheVersion: string, section: ScanSection) {
  return section.previewSourcePath
    ? getPreviewSnapshotUrl(projectId, cacheVersion, section.previewSourcePath, section.previewTargetSelector, section.previewTargetIndex)
    : null;
}

function getPagePreviewNote(page: ScanPage) {
  if (page.previewKind === "home_section") {
    return page.previewTargetSelector ? `Превью секции: ${page.previewTargetSelector}` : "Превью секции главной";
  }

  if (page.visibleInSite === false) {
    return "Блок выключен и не попадает в текущий рендер сайта";
  }

  if (page.previewKind === "raw_template") {
    return "Шаблон не связан с родительской страницей";
  }

  return null;
}

function getSectionPreviewNote(section: ScanSection) {
  if (!section.enabled) {
    return "Секция выключена в модели страницы и не попадает в текущий рендер.";
  }

  if (section.previewTargetSelector) {
    const suffix = section.previewTargetIndex ? ` #${section.previewTargetIndex + 1}` : "";
    return `Секция главной: ${section.previewTargetSelector}${suffix}`;
  }

  return "Секция главной страницы";
}

function getProjectTitle(scan: ProjectScanSummary, projectName: string) {
  const homeTitle = scan.pages.find(isHomePage)?.title?.trim();

  return homeTitle || projectName;
}

function getNodeIcon(scope: ScopeNodeData["scope"]) {
  if (scope === "project") return <Globe2 size={17} />;
  if (scope === "home_section") return <Layers3 size={17} />;
  if (scope === "page_group") return <FileText size={17} />;
  if (scope === "indexable_page") return <PanelTop size={17} />;
  if (scope === "template_block") return <Layers3 size={17} />;
  if (scope === "admin_page") return <LockKeyhole size={17} />;
  return <Settings2 size={17} />;
}

const ScopeNode = memo(function ScopeNode({ data }: NodeProps<ScopeFlowNode>) {
  const isProject = data.scope === "project";
  const hasPreview = data.previewMode === "frame" && Boolean(data.previewUrl);
  const selectedClass = data.selected ? "seo-selected" : "seo-not-selected";
  const statusClass = data.status === "coverage" ? "coverage" : data.status === "project" ? "project" : "excluded";
  const headingLabel = data.headings[0] || data.subtitle;

  return (
    <section
      className={`scope-flow-node ${selectedClass} ${statusClass} preview-${data.previewMode} ${
        hasPreview ? "has-preview cached-preview" : "no-preview"
      }`}
    >
      <NodeResizer
        color={data.selected ? "#34d399" : "#94a3b8"}
        handleClassName="scope-flow-resize-handle"
        isVisible
        lineClassName="scope-flow-resize-line"
        maxHeight={1600}
        maxWidth={2600}
        minHeight={hasPreview ? 300 : 220}
        minWidth={hasPreview ? 760 : 320}
      />
      {!isProject ? (
        <Handle className="scope-flow-handle scope-flow-handle-left" id="left" position={Position.Left} type="target" />
      ) : null}
      <div className="scope-flow-card scope-flow-drag-surface">
        <div className="scope-flow-node-top">
          <div className="scope-flow-title-group">
            <span className="scope-flow-node-icon">{getNodeIcon(data.scope)}</span>
            <span>
              <strong>{data.label}</strong>
              <small>{data.scopeLabel}</small>
            </span>
          </div>
          {!isProject && data.pageId ? (
            <label className="scope-flow-check nodrag" title="Включить в SEO scope">
              <input
                checked={data.selected}
                disabled={data.disabled}
                onChange={(event) => data.onToggle?.(data.pageId!, event.target.checked)}
                type="checkbox"
              />
              <span aria-hidden="true" />
            </label>
          ) : null}
        </div>
        <div className="scope-flow-node-copy">
          <small>Source</small>
          <p>{data.path}</p>
        </div>
        <div className="scope-flow-node-copy">
          <small>Detected intent</small>
          <p>{headingLabel}</p>
        </div>
        {data.headings.length > 0 ? (
          <div className="scope-flow-heading-strip">
            {data.headings.slice(0, 2).map((heading) => (
              <span key={`${data.pageId}-${heading}`}>{heading}</span>
            ))}
          </div>
        ) : null}
        {data.previewNote ? <div className="scope-flow-preview-note">{data.previewNote}</div> : null}
      </div>
      {hasPreview ? (
        <div className="scope-flow-preview-wing scope-flow-drag-surface" aria-label={`Превью: ${data.label}`}>
          <SnapshotPreviewFrame label={data.label} url={data.previewUrl!} />
        </div>
      ) : data.previewMode === "empty" ? (
        <div className="scope-flow-preview-wing scope-flow-preview-empty" aria-label={`Превью недоступно: ${data.label}`}>
          <div className="scope-flow-preview-frame">
            <div className="scope-flow-preview-empty-copy">
              <strong>Нет рендера</strong>
              <span>{data.previewNote || "Источник не связан с публичной страницей."}</span>
            </div>
          </div>
        </div>
      ) : null}
      <Handle className="scope-flow-handle scope-flow-handle-right" id="right" position={Position.Right} type="source" />
    </section>
  );
});

function SnapshotPreviewFrame({ label, url }: { label: string; url: string }) {
  const [loaded, setLoaded] = useState(false);
  const [failed, setFailed] = useState(false);

  return (
    <div className={`scope-flow-preview-frame ${loaded ? "is-loaded" : "is-loading"} ${failed ? "is-failed" : ""}`}>
      {!loaded ? (
        <div className="scope-flow-preview-loader">
          <strong>{failed ? "Preview failed" : "Preview"}</strong>
          <span>{failed ? "Snapshot не построился" : "1920 x 1080"}</span>
        </div>
      ) : null}
      <img
        alt={`preview ${label}`}
        decoding="async"
        loading="eager"
        onError={() => setFailed(true)}
        onLoad={() => setLoaded(true)}
        src={url}
      />
    </div>
  );
}

type ScopeFlowToolbarProps = {
  initialNodes: ScopeFlowNode[];
  layoutKey: string;
  nodes: ScopeFlowNode[];
  onApplyNodes: (nodes: ScopeFlowNode[]) => void;
  onLayoutSaved: (layout: SavedScopeLayout) => void;
  projectId: string;
  storageKey: string;
};

function ScopeFlowToolbar({ initialNodes, layoutKey, nodes, onApplyNodes, onLayoutSaved, projectId, storageKey }: ScopeFlowToolbarProps) {
  const reactFlow = useReactFlow<ScopeFlowNode, Edge>();
  const toolbarRef = useRef<HTMLDivElement | null>(null);
  const buttonRefs = useRef<Record<string, HTMLButtonElement | null>>({});
  const motionRef = useRef<Record<string, DockMotion>>({});
  const actionKeysRef = useRef(SCOPE_FLOW_TOOLBAR_KEYS);
  const frameRef = useRef<number | null>(null);
  const frameLastTsRef = useRef(0);
  const [savedPulse, setSavedPulse] = useState(false);

  const getMotion = useCallback((key: string) => {
    let motion = motionRef.current[key];

    if (!motion) {
      motion = {
        scale: 1,
        scaleVelocity: 0,
        shiftVelocity: 0,
        shiftY: 0,
        targetScale: 1,
        targetShiftY: 0
      };
      motionRef.current[key] = motion;
    }

    return motion;
  }, []);

  const renderDockFrame = useCallback(
    (timestamp = performance.now()) => {
      const keys = actionKeysRef.current;

      if (!keys.length) {
        frameRef.current = null;
        return;
      }

      const dt = frameLastTsRef.current
        ? Math.min(0.032, Math.max(0.001, (timestamp - frameLastTsRef.current) / 1000))
        : 0.016;
      frameLastTsRef.current = timestamp;
      let active = false;

      for (const key of keys) {
        const button = buttonRefs.current[key];

        if (!button) {
          continue;
        }

        const motion = getMotion(key);
        [motion.scale, motion.scaleVelocity] = stepToolbarSpring(
          motion.scale,
          motion.scaleVelocity,
          motion.targetScale,
          dt
        );
        [motion.shiftY, motion.shiftVelocity] = stepToolbarSpring(
          motion.shiftY,
          motion.shiftVelocity,
          motion.targetShiftY,
          dt
        );

        if (
          Math.abs(motion.scale - motion.targetScale) < 0.001 &&
          Math.abs(motion.scaleVelocity) < 0.001 &&
          Math.abs(motion.shiftY - motion.targetShiftY) < 0.05 &&
          Math.abs(motion.shiftVelocity) < 0.05
        ) {
          motion.scale = motion.targetScale;
          motion.scaleVelocity = 0;
          motion.shiftY = motion.targetShiftY;
          motion.shiftVelocity = 0;
        } else {
          active = true;
        }

        button.style.setProperty("--tb-scale", motion.scale.toFixed(4));
        button.style.setProperty("--tb-shift-y", `${motion.shiftY.toFixed(2)}px`);
      }

      if (active) {
        frameRef.current = window.requestAnimationFrame(renderDockFrame);
        return;
      }

      frameRef.current = null;
      frameLastTsRef.current = 0;
    },
    [getMotion]
  );

  const startDockAnimation = useCallback(() => {
    if (frameRef.current != null) {
      return;
    }

    frameLastTsRef.current = 0;
    frameRef.current = window.requestAnimationFrame(renderDockFrame);
  }, [renderDockFrame]);

  const setButtonTarget = useCallback(
    (key: string, size: number, shiftY: number) => {
      const motion = getMotion(key);

      motion.targetScale = TOOLBAR_MIN_SIZE > 0 ? size / TOOLBAR_MIN_SIZE : 1;
      motion.targetShiftY = shiftY;
      startDockAnimation();
    },
    [getMotion, startDockAnimation]
  );

  const resetMagnification = useCallback(() => {
    for (const key of actionKeysRef.current) {
      setButtonTarget(key, TOOLBAR_MIN_SIZE, 0);
    }
  }, [setButtonTarget]);

  const updateMagnification = useCallback(
    (clientY: number) => {
      const toolbarRect = toolbarRef.current?.getBoundingClientRect();

      if (!toolbarRect) {
        return;
      }

      const entries = actionKeysRef.current
        .map((key) => {
          const button = buttonRefs.current[key];

          if (!button) {
            return null;
          }

          return {
            centerY: toolbarRect.top + button.offsetTop + button.offsetHeight / 2,
            key
          };
        })
        .filter((entry): entry is { centerY: number; key: string } => Boolean(entry))
        .sort((left, right) => left.centerY - right.centerY);

      const step = TOOLBAR_MIN_SIZE + TOOLBAR_GAP;
      const lensRadius = Math.max(TOOLBAR_MIN_SIZE, Math.floor((TOOLBAR_LENS_COUNT - 1) / 2) * step + TOOLBAR_GAP / 2);
      const maxScale = TOOLBAR_MAX_SIZE / TOOLBAR_MIN_SIZE;
      const scaledNudge =
        TOOLBAR_DOCK_BASE_NUDGE *
        (lensRadius / TOOLBAR_DOCK_BASE_DISTANCE) *
        (Math.max(0, maxScale - 1) / (TOOLBAR_DOCK_BASE_SCALE - 1)) *
        TOOLBAR_DOCK_NUDGE_TIGHTNESS;

      for (const { centerY, key } of entries) {
        const distance = clientY - centerY;
        const absDistance = Math.abs(distance);
        const scale = absDistance >= lensRadius ? 1 : 1 + (maxScale - 1) * (1 - absDistance / lensRadius);
        const size = TOOLBAR_MIN_SIZE * scale;
        const shiftY =
          absDistance > lensRadius
            ? Math.sign(distance || 1) * -1 * scaledNudge
            : (-distance / lensRadius) * scaledNudge * scale;

        setButtonTarget(key, size, shiftY);
      }
    },
    [setButtonTarget]
  );

  useEffect(
    () => () => {
      if (frameRef.current != null) {
        window.cancelAnimationFrame(frameRef.current);
      }
    },
    []
  );

  const persistCurrentLayout = useCallback(
    (viewport = reactFlow.getViewport()) =>
      persistScopeLayout({
        layoutKey,
        nodes,
        onLayoutSaved,
        projectId,
        storageKey,
        viewport
      }),
    [layoutKey, nodes, onLayoutSaved, projectId, reactFlow, storageKey]
  );

  const saveLayout = useCallback(() => {
    persistCurrentLayout();
    setSavedPulse(true);
    window.setTimeout(() => setSavedPulse(false), 650);
  }, [persistCurrentLayout]);

  const refreshLayout = useCallback(() => {
    const savedLayout = readSavedScopeLayout(storageKey);
    const nextNodes = applySavedScopeLayout(initialNodes, savedLayout);

    onApplyNodes(nextNodes);
    reactFlow.setViewport(savedLayout?.viewport ?? DEFAULT_SCOPE_VIEWPORT, { duration: 180 });
  }, [initialNodes, onApplyNodes, reactFlow, storageKey]);

  const actions = [
    {
      key: "save",
      title: "Сохранить layout",
      active: savedPulse,
      onAction: saveLayout,
      icon: <Save size={18} strokeWidth={2.1} />
    },
    {
      key: "refresh",
      title: "Обновить workflow",
      onAction: refreshLayout,
      icon: <RefreshCw size={18} strokeWidth={2.1} />
    },
    {
      key: "zoom-in",
      title: "Приблизить",
      onAction: () => {
        void reactFlow.zoomIn({ duration: 180 });
      },
      icon: <Plus size={20} strokeWidth={2.2} />
    },
    {
      key: "zoom-out",
      title: "Отдалить",
      onAction: () => {
        void reactFlow.zoomOut({ duration: 180 });
      },
      icon: <Minus size={20} strokeWidth={2.2} />
    },
    {
      key: "fit-view",
      title: "Показать всё",
      onAction: () => {
        void reactFlow.fitView({ padding: 0.12, duration: 220 });
      },
      icon: <Maximize2 size={18} strokeWidth={2.1} />
    }
  ];

  return (
    <div
      className="scope-flow-toolbar nodrag nopan"
      onPointerLeave={resetMagnification}
      onPointerMove={(event) => updateMagnification(event.clientY)}
      ref={toolbarRef}
    >
      {actions.map((action) => (
        <button
          aria-label={action.title}
          aria-pressed={Boolean(action.active)}
          className="scope-flow-toolbar-button"
          key={action.key}
          onClick={(event) => {
            event.preventDefault();
            event.stopPropagation();
            action.onAction();
          }}
          onPointerDown={(event) => {
            event.stopPropagation();
          }}
          ref={(element) => {
            buttonRefs.current[action.key] = element;
          }}
          title={action.title}
          type="button"
        >
          {action.icon}
        </button>
      ))}
    </div>
  );
}

function ScopeFlowViewportSync({
  applyToken,
  layout,
  storageKey
}: {
  applyToken: number;
  layout: SavedScopeLayout | null;
  storageKey: string;
}) {
  const reactFlow = useReactFlow<ScopeFlowNode, Edge>();

  useEffect(() => {
    const savedLayout = layout ?? readSavedScopeLayout(storageKey);
    reactFlow.setViewport(savedLayout?.viewport ?? DEFAULT_SCOPE_VIEWPORT, { duration: 0 });
  }, [applyToken, reactFlow, storageKey]);

  return null;
}

function buildScopeFlow(
  scan: ProjectScanSummary,
  projectId: string,
  projectName: string,
  onTogglePage: Props["onTogglePage"]
) {
  const cacheVersion = getScanContentKey(scan);
  const nodes: ScopeFlowNode[] = [
    {
      id: "project-root",
      position: { x: 0, y: 0 },
      type: "scopeNode",
      style: {
        height: 230,
        width: ROOT_WIDTH
      },
      data: {
        description: "Корень проекта",
        disabled: true,
        headings: [],
        label: getProjectTitle(scan, projectName),
        path: "project source",
        previewKind: "project",
        previewMode: "none",
        previewNote: null,
        previewUrl: null,
        selected: true,
        scope: "project",
        scopeLabel: "проект",
        status: "project",
        subtitle: "Визуальная карта SEO scope после сканирования проекта",
        visibleInSite: true
      }
    }
  ];
  const edges: Edge[] = [];

  const addPageNode = (
    page: ScanPage,
    position: { x: number; y: number },
    width: number,
    height: number,
    previewMode: ScopeNodeData["previewMode"] = "frame"
  ) => {
    const scope = page.scope;
    const headings = page.headings.map((heading) => heading.text).filter(Boolean);

    nodes.push({
      id: `${scope}-${page.pageId}`,
      position,
      style: {
        height,
        width
      },
      type: "scopeNode",
      data: {
        description: page.scopeReason,
        disabled: false,
        headings,
        label: getPageTitle(page),
        onToggle: onTogglePage,
        pageId: page.pageId,
        path: getPagePath(page),
        previewKind: page.previewKind,
        previewMode,
        previewNote: getPagePreviewNote(page),
        previewUrl: previewMode === "frame" ? getPagePreviewUrl(projectId, cacheVersion, page) : null,
        selected: page.selected,
        scope,
        scopeLabel: scopeLabels[scope],
        status: page.usedForCoverage ? "coverage" : "excluded",
        subtitle: getPageSubtitle(page),
        visibleInSite: page.visibleInSite
      }
    });
  };

  const addSectionNode = (section: ScanSection, position: { x: number; y: number }, width: number, height: number) => {
    const previewMode: ScopeNodeData["previewMode"] = section.enabled ? "frame" : "empty";

    nodes.push({
      id: `home-section-${section.sectionId}`,
      position,
      style: {
        height,
        width
      },
      type: "scopeNode",
      data: {
        description: section.scopeReason,
        disabled: !section.pageId,
        headings: [],
        label: section.title,
        onToggle: onTogglePage,
        pageId: section.pageId ?? undefined,
        path: `${section.sourcePath} · ${section.enabled ? "включено" : "выключено"}`,
        previewKind: section.previewKind,
        previewMode,
        previewNote: getSectionPreviewNote(section),
        previewUrl: previewMode === "frame" ? getSectionPreviewUrl(projectId, cacheVersion, section) : null,
        selected: section.selected,
        sectionId: section.sectionId,
        scope: "home_section",
        scopeLabel: section.enabled ? "секция главной" : "выключенная секция",
        status: section.enabled ? "coverage" : "excluded",
        subtitle: section.enabled ? "Секция главной страницы" : "Выключена в home.json",
        visibleInSite: section.enabled
      }
    });
  };

  const addEdge = (source: string, target: string, className: string) => {
    edges.push({
      id: `${source}-${target}`,
      source,
      sourceHandle: "right",
      target,
      targetHandle: "left",
      className
    });
  };

  const indexablePages = scan.pages
    .filter((page) => page.scope === "indexable_page")
    .sort((left, right) => {
      if (isHomePage(left)) return -1;
      if (isHomePage(right)) return 1;
      return getPagePath(left).localeCompare(getPagePath(right), "ru");
    });
  const homePage = indexablePages.find(isHomePage) ?? indexablePages[0] ?? null;
  const otherIndexablePages = indexablePages.filter((page) => page !== homePage);
  const siteSections = [...(scan.siteSections ?? [])].sort((left, right) => left.order - right.order);
  const adminPages = scan.pages
    .filter((page) => page.scope === "admin_page")
    .sort((left, right) => getPagePath(left).localeCompare(getPagePath(right), "ru"));
  const technicalPages = scan.pages
    .filter((page) => page.scope === "technical_page")
    .sort((left, right) => getPagePath(left).localeCompare(getPagePath(right), "ru"));

  const routeX = ROOT_WIDTH + X_GAP;
  const sectionX = routeX + SELECTED_NODE_WIDTH + X_GAP;

  if (homePage) {
    const homeNodeId = `${homePage.scope}-${homePage.pageId}`;
    addPageNode(homePage, { x: routeX, y: 0 }, SELECTED_NODE_WIDTH, SELECTED_NODE_HEIGHT);
    addEdge("project-root", homeNodeId, homePage.usedForCoverage ? "scope-flow-edge coverage" : "scope-flow-edge muted");
  }

  otherIndexablePages.forEach((page, index) => {
    const y = SELECTED_NODE_HEIGHT + Y_GAP + index * (COMPACT_NODE_HEIGHT + 56);
    const nodeId = `${page.scope}-${page.pageId}`;

    addPageNode(page, { x: routeX, y }, COMPACT_NODE_WIDTH, COMPACT_NODE_HEIGHT, "none");
    addEdge("project-root", nodeId, page.usedForCoverage ? "scope-flow-edge coverage" : "scope-flow-edge muted");
  });

  siteSections.forEach((section, index) => {
    const column = index % MAX_COLUMNS;
    const row = Math.floor(index / MAX_COLUMNS);
    const nodeId = `home-section-${section.sectionId}`;
    const previousSection = siteSections[index - 1];
    const width = section.enabled ? NODE_WIDTH : NODE_WIDTH - 80;

    addSectionNode(
      section,
      {
        x: sectionX + column * (NODE_WIDTH + X_GAP),
        y: row * (NODE_HEIGHT + Y_GAP)
      },
      width,
      NODE_HEIGHT
    );

    if (index === 0) {
      addEdge(homePage ? `${homePage.scope}-${homePage.pageId}` : "project-root", nodeId, "scope-flow-edge muted");
    } else if (previousSection) {
      addEdge(`home-section-${previousSection.sectionId}`, nodeId, "scope-flow-edge muted");
    }
  });

  const routeRowsHeight =
    SELECTED_NODE_HEIGHT + (otherIndexablePages.length > 0 ? Y_GAP + otherIndexablePages.length * (NODE_HEIGHT + Y_GAP) : 0);
  const sectionRowsHeight = Math.ceil(siteSections.length / MAX_COLUMNS) * (NODE_HEIGHT + Y_GAP);
  const lowerY = Math.max(routeRowsHeight, sectionRowsHeight, SELECTED_NODE_HEIGHT) + 170;

  adminPages.forEach((page, index) => {
    const nodeId = `${page.scope}-${page.pageId}`;
    const previousPage = adminPages[index - 1];

    addPageNode(page, { x: routeX, y: lowerY + index * (COMPACT_NODE_HEIGHT + 84) }, NODE_WIDTH, NODE_HEIGHT);
    addEdge(previousPage ? `${previousPage.scope}-${previousPage.pageId}` : "project-root", nodeId, "scope-flow-edge muted");
  });

  technicalPages.forEach((page, index) => {
    const nodeId = `${page.scope}-${page.pageId}`;
    const previousPage = technicalPages[index - 1];

    addPageNode(
      page,
      { x: sectionX, y: lowerY + index * (COMPACT_NODE_HEIGHT + 84) },
      COMPACT_NODE_WIDTH,
      COMPACT_NODE_HEIGHT
    );
    addEdge(previousPage ? `${previousPage.scope}-${previousPage.pageId}` : "project-root", nodeId, "scope-flow-edge muted");
  });

  if (!homePage && siteSections.length === 0 && adminPages.length === 0 && technicalPages.length === 0) {
    for (const [index, page] of scan.pages.entries()) {
      const nodeId = `${page.scope}-${page.pageId}`;

      addPageNode(page, { x: routeX, y: index * (NODE_HEIGHT + Y_GAP) }, NODE_WIDTH, NODE_HEIGHT);
      addEdge("project-root", nodeId, "scope-flow-edge muted");
    }
  }

  return { edges, nodes };
}

function getScopeStats(scan: ProjectScanSummary) {
  const pageStats = scan.pages.reduce(
    (stats, page) => {
      stats.total += 1;
      if (page.scope === "indexable_page") stats.indexable += 1;
      if (page.scope === "admin_page") stats.admin += 1;
      if (page.scope === "technical_page") stats.technical += 1;
      if (page.selected && page.scope === "indexable_page") stats.selectedIndexable += 1;
      if (page.selected && page.scope === "admin_page") stats.selectedAdmin += 1;
      if (page.selected && page.scope === "technical_page") stats.selectedTechnical += 1;
      if (page.selected && page.scope !== "indexable_page") stats.selectedExcluded += 1;
      return stats;
    },
    {
      admin: 0,
      indexable: 0,
      selectedAdmin: 0,
      selectedExcluded: 0,
      selectedIndexable: 0,
      selectedTechnical: 0,
      technical: 0,
      total: 0
    }
  );
  const selectedSections = scan.siteSections.filter((section) => section.selected && section.enabled && section.pageId).length;
  const enabledSections = scan.siteSections.filter((section) => section.enabled && section.pageId).length;

  return {
    ...pageStats,
    enabledSections,
    selectedSections,
    selectedWorkspaceItems: pageStats.selectedIndexable + selectedSections + pageStats.selectedAdmin + pageStats.selectedTechnical
  };
}

function shouldAutosaveNodeChanges(changes: NodeChange<ScopeFlowNode>[]) {
  return changes.some((change) => change.type === "position" || change.type === "dimensions");
}

export default function ProjectScopeFlow({
  isBusy,
  onApplyRecommendedScope,
  onClearSelection,
  onTogglePage,
  projectId,
  projectName,
  scan
}: Props) {
  const stats = getScopeStats(scan);
  const onTogglePageRef = useRef(onTogglePage);
  const handleTogglePage = useCallback<Props["onTogglePage"]>((pageId, selected) => {
    onTogglePageRef.current(pageId, selected);
  }, []);
  const layoutKey = getScanContentKey(scan);
  const layoutStorageKey = getScopeLayoutStorageKey(projectId, layoutKey);
  const flowResetKey = `${projectId}:${scan.id}:${scan.pages.length}:${scan.siteSections.length}`;
  const initialFlow = useMemo(
    () => buildScopeFlow(scan, projectId, projectName, handleTogglePage),
    [flowResetKey, handleTogglePage, projectName]
  );
  const flowEdges = useMemo(
    () => buildScopeFlow(scan, projectId, projectName, handleTogglePage).edges,
    [handleTogglePage, projectId, projectName, scan]
  );
  const nodeLiveState = useMemo(
    () => ({
      pages: new Map(scan.pages.map((page) => [page.pageId, { selected: page.selected, usedForCoverage: page.usedForCoverage }])),
      sections: new Map(scan.siteSections.map((section) => [section.sectionId, { enabled: section.enabled, selected: section.selected }]))
    }),
    [scan.pages, scan.siteSections]
  );
  const [nodes, setNodes, onNodesChange] = useNodesState<ScopeFlowNode>(initialFlow.nodes);
  const [activeLayout, setActiveLayout] = useState<SavedScopeLayout | null>(null);
  const [layoutApplyToken, setLayoutApplyToken] = useState(0);
  const autosaveReadyRef = useRef(false);
  const autosaveTimerRef = useRef<number | null>(null);
  const latestNodesRef = useRef<ScopeFlowNode[]>(initialFlow.nodes);
  const latestViewportRef = useRef(DEFAULT_SCOPE_VIEWPORT);
  const applyNodes = useCallback(
    (nextNodes: ScopeFlowNode[]) => {
      setNodes(nextNodes);
    },
    [setNodes]
  );
  const handleLayoutSaved = useCallback((layout: SavedScopeLayout) => {
    if (layout.viewport) {
      latestViewportRef.current = layout.viewport;
    }

    setActiveLayout(layout);
  }, []);
  const persistCurrentLayout = useCallback(
    (viewport = latestViewportRef.current) => {
      persistScopeLayout({
        layoutKey,
        nodes: latestNodesRef.current,
        onLayoutSaved: handleLayoutSaved,
        projectId,
        storageKey: layoutStorageKey,
        viewport
      });
    },
    [handleLayoutSaved, layoutKey, layoutStorageKey, projectId]
  );
  const scheduleLayoutAutosave = useCallback(
    () => {
      if (!autosaveReadyRef.current) {
        return;
      }

      if (autosaveTimerRef.current != null) {
        window.clearTimeout(autosaveTimerRef.current);
      }

      autosaveTimerRef.current = window.setTimeout(() => {
        autosaveTimerRef.current = null;
        persistCurrentLayout();
      }, 850);
    },
    [persistCurrentLayout]
  );
  const handleNodesChange = useCallback(
    (changes: NodeChange<ScopeFlowNode>[]) => {
      onNodesChange(changes);

      if (shouldAutosaveNodeChanges(changes)) {
        scheduleLayoutAutosave();
      }
    },
    [onNodesChange, scheduleLayoutAutosave]
  );

  useEffect(() => {
    onTogglePageRef.current = onTogglePage;
  }, [onTogglePage]);

  useEffect(() => {
    latestNodesRef.current = nodes;
  }, [nodes]);

  useEffect(
    () => () => {
      if (autosaveTimerRef.current != null) {
        window.clearTimeout(autosaveTimerRef.current);
      }
    },
    []
  );

  useEffect(() => {
    let cancelled = false;
    const localLayout = readSavedScopeLayout(layoutStorageKey);

    autosaveReadyRef.current = false;
    if (autosaveTimerRef.current != null) {
      window.clearTimeout(autosaveTimerRef.current);
      autosaveTimerRef.current = null;
    }
    latestViewportRef.current = localLayout?.viewport ?? DEFAULT_SCOPE_VIEWPORT;
    setActiveLayout(localLayout);
    setLayoutApplyToken((currentToken) => currentToken + 1);
    setNodes(applySavedScopeLayout(initialFlow.nodes, localLayout));

    void fetchProjectScopeLayout(projectId, layoutKey)
      .then(({ layout }) => {
        if (cancelled || !layout) {
          return;
        }

        writeSavedScopeLayout(layoutStorageKey, layout);
        latestViewportRef.current = layout.viewport ?? DEFAULT_SCOPE_VIEWPORT;
        setActiveLayout(layout);
        setLayoutApplyToken((currentToken) => currentToken + 1);
        setNodes(applySavedScopeLayout(initialFlow.nodes, layout));
      })
      .catch((error) => {
        console.warn("Failed to load project scope layout.", error);
      })
      .finally(() => {
        window.setTimeout(() => {
          if (!cancelled) {
            autosaveReadyRef.current = true;
          }
        }, 250);
      });

    return () => {
      cancelled = true;
      autosaveReadyRef.current = false;
    };
  }, [flowResetKey, initialFlow.nodes, layoutKey, layoutStorageKey, projectId, setNodes]);

  useEffect(() => {
    setNodes((currentNodes) =>
      currentNodes.map((node) => {
        const pageState = node.data.pageId ? nodeLiveState.pages.get(node.data.pageId) : undefined;
        const sectionState = node.data.sectionId ? nodeLiveState.sections.get(node.data.sectionId) : undefined;
        const disabled = node.data.scope === "project" || isBusy || (node.data.scope === "home_section" && !node.data.pageId);
        const selected = sectionState?.selected ?? pageState?.selected ?? node.data.selected;
        const status =
          node.data.scope === "project"
            ? "project"
            : sectionState
              ? sectionState.enabled
                ? "coverage"
                : "excluded"
              : pageState
                ? pageState.usedForCoverage
                  ? "coverage"
                  : "excluded"
                : node.data.status;

        if (
          node.data.disabled === disabled &&
          node.data.selected === selected &&
          node.data.status === status &&
          node.data.onToggle === handleTogglePage
        ) {
          return node;
        }

        return {
          ...node,
          data: {
            ...node.data,
            disabled,
            onToggle: handleTogglePage,
            selected,
            status
          }
        };
      })
    );
  }, [handleTogglePage, isBusy, nodeLiveState, setNodes]);

  const selectionSignature = useMemo(
    () =>
      [
        ...scan.pages.map((page) => `${page.pageId}:${page.selected ? 1 : 0}`),
        ...scan.siteSections.map((section) => `${section.sectionId}:${section.selected ? 1 : 0}`)
      ].join("|"),
    [scan.pages, scan.siteSections]
  );

  useEffect(() => {
    scheduleLayoutAutosave();
  }, [scheduleLayoutAutosave, selectionSignature]);

  const nodeTypes = useMemo(() => ({ scopeNode: ScopeNode }), []);
  const groups = scopePriority
    .map((scope) => ({
      count: scope === "template_block" ? scan.siteSections.length : scan.pages.filter((page) => page.scope === scope).length,
      selected:
        scope === "template_block"
          ? scan.siteSections.filter((section) => section.selected && section.enabled && section.pageId).length
          : scan.pages.filter((page) => page.scope === scope && page.selected).length,
      scope
    }))
    .filter((group) => group.count > 0);

  return (
    <section className="project-scope-flow-panel">
      <div className="project-scope-flow-head">
        <strong>Конфигурация проекта</strong>
      </div>
      <div className="project-scope-flow-controls">
        <div className="project-scope-flow-legend">
          {groups.map((group) => (
            <span className={`scope-legend-item ${group.scope}`} key={group.scope}>
              <FileText size={13} />
              {scopeGroupNames[group.scope]} · {group.selected}/{group.count}
            </span>
          ))}
        </div>
        <div className="project-scope-flow-actions">
          <button className="active" disabled={isBusy} onClick={onApplyRecommendedScope} type="button">
            SEO scope
          </button>
          <button disabled={isBusy || stats.selectedWorkspaceItems === 0} onClick={onClearSelection} type="button">
            Снять все
          </button>
        </div>
      </div>
      <div className="project-scope-flow-canvas">
        <ReactFlowProvider>
          <ReactFlow
            colorMode="light"
            defaultViewport={DEFAULT_SCOPE_VIEWPORT}
            edges={flowEdges}
            minZoom={0.12}
            nodes={nodes}
            nodesDraggable
            nodesConnectable={false}
            nodesFocusable
            nodeTypes={nodeTypes}
            nodeDragThreshold={0}
            onNodesChange={handleNodesChange}
            onlyRenderVisibleElements
            panOnDrag
            panOnScroll={false}
            proOptions={{ hideAttribution: true }}
            selectNodesOnDrag={false}
            zoomOnPinch
            zoomOnScroll
            zoomOnDoubleClick={false}
          >
            <Background color="#d7e7dd" gap={22} size={1} />
            <ScopeFlowViewportSync applyToken={layoutApplyToken} layout={activeLayout} storageKey={layoutStorageKey} />
            <ScopeFlowToolbar
              initialNodes={initialFlow.nodes}
              layoutKey={layoutKey}
              nodes={nodes}
              onApplyNodes={applyNodes}
              onLayoutSaved={handleLayoutSaved}
              projectId={projectId}
              storageKey={layoutStorageKey}
            />
            <MiniMap
              maskColor="rgba(255,255,255,0.74)"
              nodeBorderRadius={8}
              nodeColor={(node) => {
                const data = node.data as ScopeNodeData;
                if (data.scope === "project") return "#83918b";
                if (data.scope === "indexable_page") return "#bfc9c4";
                if (data.scope === "home_section") return "#d3ddd8";
                if (data.scope === "admin_page") return "#d7dedb";
                return "#e1e6e3";
              }}
              pannable
              zoomable
            />
          </ReactFlow>
        </ReactFlowProvider>
      </div>
    </section>
  );
}
