﻿import { describe, it, expect, vi, beforeEach } from "vitest";
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import type { ReactNode } from "react";
import { GenerationComposer } from "@/components/composer/GenerationComposer";
import { useWorkflowStore } from "@/store/workflowStore";
import type {
  EaseCurveNodeData,
  GenerateAudioNodeData,
  GenerateVideoNodeData,
  ImageInputNodeData,
  NanoBananaNodeData,
  PromptNodeData,
  VideoStitchNodeData,
  WorkflowEdge,
  WorkflowNode,
} from "@/types";

vi.mock("@xyflow/react", async () => {
  const actual = await vi.importActual("@xyflow/react");
  return {
    ...actual,
    useReactFlow: () => ({
      screenToFlowPosition: (position: { x: number; y: number }) => position,
    }),
    useViewport: () => ({ x: 0, y: 0, zoom: 1 }),
    ViewportPortal: ({ children }: { children: ReactNode }) => <>{children}</>,
  };
});

vi.mock("@/components/modals/ModelSearchDialog", () => ({
  ModelSearchDialog: (props: {
    initialCapabilityFilter: string;
    title?: string;
    onModelSelected: (model: {
      id: string;
      name: string;
      provider: "newapiwg" | "fal" | "kie";
      capabilities: Array<"text-to-image" | "image-to-image" | "text-to-video" | "image-to-video" | "text-to-audio">;
    }) => void;
  }) => (
    <div data-testid="model-dialog" data-filter={props.initialCapabilityFilter} data-title={props.title}>
      <button
        type="button"
        onClick={() =>
          props.onModelSelected({
            id: "apiyi_seedream5.0",
            name: "apiyi_seedream5.0",
            provider: "newapiwg",
            capabilities: ["text-to-image", "image-to-image"],
          })
        }
      >
        Select image model
      </button>
      <button
        type="button"
        onClick={() =>
          props.onModelSelected({
            id: "fal-video",
            name: "Fal Video",
            provider: "fal",
            capabilities: ["text-to-video", "image-to-video"],
          })
        }
      >
        Select video model
      </button>
      <button
        type="button"
        onClick={() =>
          props.onModelSelected({
            id: "elevenlabs/sound-effect-v2",
            name: "ElevenLabs Sound Effects v2",
            provider: "kie",
            capabilities: ["text-to-audio"],
          })
        }
      >
        Select audio model
      </button>
    </div>
  ),
}));

const originalActions = {
  addNode: useWorkflowStore.getState().addNode,
  updateNodeData: useWorkflowStore.getState().updateNodeData,
  regenerateNode: useWorkflowStore.getState().regenerateNode,
  selectSingleNode: useWorkflowStore.getState().selectSingleNode,
  getConnectedInputs: useWorkflowStore.getState().getConnectedInputs,
};

function imageNode(id: string, selected = false, data: Partial<NanoBananaNodeData> = {}): WorkflowNode {
  return {
    id,
    type: "nanoBanana",
    selected,
    position: { x: 0, y: 0 },
    data: {
      inputImages: [],
      inputPrompt: "image prompt",
      outputImage: null,
      aspectRatio: "16:9",
      resolution: "2K",
      model: "nano-banana-pro",
      selectedModel: {
        provider: "newapiwg",
        modelId: "apiyi_nano_banana_2",
        displayName: "apiyi_nano_banana_2",
        capabilities: ["text-to-image", "image-to-image"],
      },
      useGoogleSearch: false,
      useImageSearch: false,
      status: "idle",
      error: null,
      imageHistory: [],
      selectedHistoryIndex: 0,
      ...data,
    },
  } as WorkflowNode;
}

function pricedImageNode(id: string, selected = false, data: Partial<NanoBananaNodeData> = {}): WorkflowNode {
  return imageNode(id, selected, {
    selectedModel: {
      provider: "newapiwg",
      modelId: "wan-2.7",
      displayName: "万相 2.7",
      capabilities: ["text-to-image", "image-to-image"],
      pricing: { type: "per-run", amount: 2 },
    },
    ...data,
  });
}

function imageInputNode(id: string, selected = false, data: Partial<ImageInputNodeData> = {}): WorkflowNode {
  return {
    id,
    type: "imageInput",
    selected,
    position: { x: 0, y: 0 },
    data: {
      image: "data:image/png;base64,input",
      filename: "input.png",
      dimensions: { width: 640, height: 480 },
      ...data,
    },
  } as WorkflowNode;
}

function videoNode(id: string, selected = false, data: Partial<GenerateVideoNodeData> = {}): WorkflowNode {
  return {
    id,
    type: "generateVideo",
    selected,
    position: { x: 0, y: 0 },
    data: {
      inputImages: [],
      inputPrompt: "video prompt",
      outputVideo: null,
      selectedModel: {
        provider: "newapiwg",
        modelId: "doubao-seedance-2-0-260128",
        displayName: "doubao-seedance-2-0-260128",
        capabilities: ["text-to-video", "image-to-video"],
      },
      parameters: {},
      parameterSchema: [
        {
          name: "duration",
          type: "integer",
          label: "Duration",
          options: [
            { label: "4", value: "4" },
            { label: "6", value: "6" },
            { label: "8", value: "8" },
          ],
        },
        {
          name: "resolution",
          type: "string",
          label: "Resolution",
          options: [
            { label: "720p", value: "720p" },
            { label: "1080p", value: "1080p" },
          ],
        },
        { name: "sound", type: "boolean", label: "Sound" },
      ],
      status: "idle",
      error: null,
      videoHistory: [],
      selectedVideoHistoryIndex: 0,
      ...data,
    },
  } as WorkflowNode;
}

function audioNode(id: string, selected = false, data: Partial<GenerateAudioNodeData> = {}): WorkflowNode {
  return {
    id,
    type: "generateAudio",
    selected,
    position: { x: 0, y: 0 },
    data: {
      inputPrompt: "audio prompt",
      outputAudio: null,
      selectedModel: {
        provider: "kie",
        modelId: "elevenlabs/turbo-v2.5",
        displayName: "ElevenLabs Turbo v2.5",
        capabilities: ["text-to-audio"],
      },
      parameters: {},
      parameterSchema: [
        {
          name: "duration",
          type: "integer",
          label: "Duration",
          options: [
            { label: "4", value: "4" },
            { label: "6", value: "6" },
            { label: "8", value: "8" },
          ],
        },
        {
          name: "resolution",
          type: "string",
          label: "Resolution",
          options: [
            { label: "720p", value: "720p" },
            { label: "1080p", value: "1080p" },
          ],
        },
        { name: "sound", type: "boolean", label: "Sound" },
      ],
      status: "idle",
      error: null,
      audioHistory: [],
      selectedAudioHistoryIndex: 0,
      duration: null,
      format: null,
      ...data,
    },
  } as WorkflowNode;
}

function videoStitchNode(id: string, selected = false, data: Partial<VideoStitchNodeData> = {}): WorkflowNode {
  return {
    id,
    type: "videoStitch",
    selected,
    position: { x: 0, y: 0 },
    data: {
      clips: [],
      clipOrder: [],
      outputVideo: null,
      loopCount: 1,
      status: "idle",
      error: null,
      progress: 0,
      encoderSupported: true,
      ...data,
    },
  } as WorkflowNode;
}

function easeCurveNode(id: string, selected = false, data: Partial<EaseCurveNodeData> = {}): WorkflowNode {
  return {
    id,
    type: "easeCurve",
    selected,
    position: { x: 0, y: 0 },
    data: {
      bezierHandles: [0.42, 0, 0.58, 1],
      easingPreset: "easeInOutSine",
      inheritedFrom: null,
      outputDuration: 5,
      outputVideo: null,
      status: "idle",
      error: null,
      progress: 0,
      encoderSupported: true,
      ...data,
    },
  } as WorkflowNode;
}

function promptNode(id: string, prompt: string, selected = false): WorkflowNode {
  return {
    id,
    type: "prompt",
    selected,
    position: { x: 0, y: 0 },
    data: { prompt } satisfies PromptNodeData,
  } as WorkflowNode;
}

function textEdge(source: string, target: string): WorkflowEdge {
  return {
    id: `edge-${source}-${target}-text-text`,
    source,
    sourceHandle: "text",
    target,
    targetHandle: "text",
    data: {},
  } as WorkflowEdge;
}

function videoEdge(source: string, target: string, index: number, targetHandle = `video-${index}`): WorkflowEdge {
  return {
    id: `edge-${source}-${target}-video-${index}`,
    source,
    sourceHandle: "video",
    target,
    targetHandle,
    data: { createdAt: index },
  } as WorkflowEdge;
}

describe("GenerationComposer", () => {
  beforeEach(() => {
    vi.clearAllMocks();
    useWorkflowStore.setState({
      nodes: [],
      edges: [],
      dimmedNodeIds: new Set<string>(),
      isRunning: false,
      isModalOpen: false,
      addNode: originalActions.addNode,
      updateNodeData: originalActions.updateNodeData,
      regenerateNode: vi.fn().mockResolvedValue(undefined) as typeof originalActions.regenerateNode,
      selectSingleNode: originalActions.selectSingleNode,
      getConnectedInputs: originalActions.getConnectedInputs,
    });
  });

  it("does not render when no node is selected", () => {
    render(<GenerationComposer />);

    expect(screen.queryByTestId("node-inline-composer")).not.toBeInTheDocument();
    expect(screen.queryByLabelText("Generate")).not.toBeInTheDocument();
  });

  it("does not render a single-node editor for multi-select", () => {
    useWorkflowStore.setState({
      nodes: [
        imageNode("img-1", true),
        imageNode("img-2", true),
      ],
    });

    render(<GenerationComposer />);

    expect(screen.queryByTestId("node-inline-composer")).not.toBeInTheDocument();
    expect(screen.queryByLabelText("Generate")).not.toBeInTheDocument();
  });

  it("centers the selected node editor below the selected node", () => {
    useWorkflowStore.setState({
      nodes: [
        {
          ...imageNode("img-1", true),
          position: { x: 500, y: 240 },
          measured: { width: 260, height: 180 },
        } as WorkflowNode,
      ],
    });

    render(<GenerationComposer />);

    expect(screen.getByTestId("node-inline-composer")).toHaveStyle({
      left: "310px",
      top: "436px",
    });
    expect(screen.getByTestId("node-inline-composer-panel")).toHaveStyle({
      width: "640px",
    });
    expect(screen.getByPlaceholderText(/Describe what you want to generate/)).toHaveClass("h-36");
  });

  it("places the selected node editor above the node when below would leave the viewport", () => {
    const originalInnerHeight = window.innerHeight;
    const originalInnerWidth = window.innerWidth;
    Object.defineProperty(window, "innerHeight", { configurable: true, value: 500 });
    Object.defineProperty(window, "innerWidth", { configurable: true, value: 800 });
    useWorkflowStore.setState({
      nodes: [
        {
          ...imageNode("img-1", true),
          position: { x: 80, y: 420 },
          measured: { width: 240, height: 100 },
        } as WorkflowNode,
      ],
    });

    render(<GenerationComposer />);

    expect(screen.getByTestId("node-inline-composer")).toHaveStyle({
      left: "16px",
      top: "104px",
    });
    Object.defineProperty(window, "innerHeight", { configurable: true, value: originalInnerHeight });
    Object.defineProperty(window, "innerWidth", { configurable: true, value: originalInnerWidth });
  });

  it("reruns the selected image node without creating another node", async () => {
    useWorkflowStore.setState({ nodes: [imageNode("img-1", true)] });
    render(<GenerationComposer />);

    const prompt = screen.getByPlaceholderText(/Describe what you want to generate/) as HTMLTextAreaElement;
    fireEvent.change(prompt, { target: { value: "edited image prompt" } });
    fireEvent.click(screen.getByLabelText("Generate"));

    await waitFor(() => {
      expect(useWorkflowStore.getState().regenerateNode).toHaveBeenCalledWith("img-1");
    });
    expect((useWorkflowStore.getState().nodes[0].data as NanoBananaNodeData).inputPrompt).toBe("edited image prompt");
    expect(useWorkflowStore.getState().nodes).toHaveLength(1);
  });

  it("does not let composer clicks bubble into the canvas selection layer", () => {
    const onPointerDown = vi.fn();
    const onMouseDown = vi.fn();
    const onClick = vi.fn();
    useWorkflowStore.setState({ nodes: [imageNode("img-1", true)] });

    render(
      <div onPointerDown={onPointerDown} onMouseDown={onMouseDown} onClick={onClick}>
        <GenerationComposer />
      </div>
    );

    const generateButton = screen.getByLabelText("Generate");
    fireEvent.pointerDown(generateButton);
    fireEvent.mouseDown(generateButton);
    fireEvent.click(generateButton);

    expect(onPointerDown).not.toHaveBeenCalled();
    expect(onMouseDown).not.toHaveBeenCalled();
    expect(onClick).not.toHaveBeenCalled();
  });

  it("hides the temporary translate and settings actions", () => {
    useWorkflowStore.setState({ nodes: [imageNode("img-1", true)] });
    render(<GenerationComposer />);

    expect(screen.queryByRole("button", { name: "Translate" })).not.toBeInTheDocument();
    expect(screen.queryByRole("button", { name: "Settings" })).not.toBeInTheDocument();
  });

  it("does not render multi-angle controls in the bottom composer", () => {
    useWorkflowStore.setState({ nodes: [imageInputNode("input-1", true)] });
    render(<GenerationComposer />);

    expect(screen.queryByRole("button", { name: "Multi-angle" })).not.toBeInTheDocument();
    expect(screen.queryByText("Multi-Angle Editor")).not.toBeInTheDocument();
  });

  it("collapses and expands the bottom composer without losing the selected node context", () => {
    useWorkflowStore.setState({ nodes: [imageNode("img-1", true, { inputPrompt: "visible prompt" })] });
    render(<GenerationComposer />);

    expect(screen.getByDisplayValue("visible prompt")).toBeInTheDocument();
    fireEvent.click(screen.getByLabelText("Collapse composer"));

    expect(screen.queryByDisplayValue("visible prompt")).not.toBeInTheDocument();
    expect(screen.getByText("apiyi_nano_banana_2")).toBeInTheDocument();
    expect(screen.getByText("visible prompt")).toBeInTheDocument();

    fireEvent.click(screen.getByLabelText("Expand composer"));
    expect(screen.getByDisplayValue("visible prompt")).toBeInTheDocument();
  });

  it("expands the collapsed composer when clicking the summary content", () => {
    useWorkflowStore.setState({ nodes: [imageNode("img-1", true, { inputPrompt: "visible prompt" })] });
    render(<GenerationComposer />);

    fireEvent.click(screen.getByLabelText("Collapse composer"));
    fireEvent.click(screen.getByText("visible prompt"));

    expect(screen.getByDisplayValue("visible prompt")).toBeInTheDocument();
  });

  it("opens model selection from the collapsed model label without expanding", () => {
    useWorkflowStore.setState({ nodes: [imageNode("img-1", true, { inputPrompt: "visible prompt" })] });
    render(<GenerationComposer />);

    fireEvent.click(screen.getByLabelText("Collapse composer"));
    fireEvent.click(screen.getByLabelText("Select model"));

    expect(screen.getByTestId("model-dialog")).toHaveAttribute("data-filter", "image");
    expect(screen.queryByDisplayValue("visible prompt")).not.toBeInTheDocument();
  });

  it("shows the concrete selected image model name in the composer", async () => {
    useWorkflowStore.setState({ nodes: [imageNode("img-1", true)] });
    render(<GenerationComposer />);

    fireEvent.click(screen.getByLabelText("Select model"));
    fireEvent.click(screen.getByText("Select image model"));

    await waitFor(() => {
      expect(screen.getByText("apiyi_seedream5.0")).toBeInTheDocument();
    });
    expect(screen.queryByText("PopiArt Nano Pro")).not.toBeInTheDocument();
    expect((useWorkflowStore.getState().nodes[0].data as NanoBananaNodeData).selectedModel?.displayName)
      .toBe("apiyi_seedream5.0");
  });

  it("shows NewApiWG point cost on the generate button when model pricing is available", () => {
    useWorkflowStore.setState({ nodes: [pricedImageNode("img-1", true)] });
    render(<GenerationComposer />);

    expect(screen.getByRole("button", { name: "2 points" })).toBeInTheDocument();
  });

  it("multiplies NewApiWG image point cost by image count", () => {
    useWorkflowStore.setState({
      nodes: [pricedImageNode("img-1", true, { imageCount: 4 })],
    });
    render(<GenerationComposer />);

    expect(screen.getByRole("button", { name: "8 points" })).toBeInTheDocument();
  });

  it("uses NewApiWG pricing variant matching the selected resolution", () => {
    useWorkflowStore.setState({
      nodes: [
        imageNode("img-1", true, {
          resolution: "4K",
          selectedModel: {
            provider: "newapiwg",
            modelId: "apiyi_nano_banana_2",
            displayName: "apiyi_nano_banana_2",
            capabilities: ["text-to-image", "image-to-image"],
            pricing: {
              type: "per-run",
              amount: 15,
              variants: [
                { dimensions: { size: "1k" }, amount: 15 },
                { dimensions: { size: "4k" }, amount: 24 },
              ],
            },
          },
        }),
      ],
    });
    render(<GenerationComposer />);

    expect(screen.getByRole("button", { name: "24 points" })).toBeInTheDocument();
  });

  it("uses NewApiWG video pricing variant matching duration, resolution, sound, and reference input", () => {
    useWorkflowStore.setState({
      nodes: [
        videoNode("vid-1", true, {
          inputImages: ["data:image/png;base64,ref"],
          durationSeconds: 5,
          resolution: "720p",
          soundEnabled: true,
          selectedModel: {
            provider: "newapiwg",
            modelId: "viduq3-turbo",
            displayName: "viduq3-turbo",
            capabilities: ["text-to-video", "image-to-video"],
            pricing: {
              type: "per-run",
              amount: 20,
              variants: [
                {
                  dimensions: {
                    action: "textgenerate",
                    duration: "5s",
                    resolution: "720p",
                    has_video_input: false,
                    has_sound: true,
                  },
                  amount: 55,
                },
                {
                  dimensions: {
                    action: "referencegenerate",
                    duration: "5s",
                    resolution: "720p",
                    has_video_input: true,
                    has_sound: true,
                  },
                  amount: 70,
                },
              ],
            },
          },
        }),
      ],
    });
    render(<GenerationComposer />);

    expect(screen.getByRole("button", { name: "70 points" })).toBeInTheDocument();
  });

  it("uses live Seedance video point estimates from NewApiWG", async () => {
    const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
      const url = String(input);
      if (url.startsWith("/api/points/estimate")) {
        return new Response(JSON.stringify({
          success: true,
          data: {
            estimated_points: 123,
          },
        }), { status: 200 });
      }
      if (url.startsWith("/api/models")) {
        return new Response(JSON.stringify({ success: true, models: [] }), { status: 200 });
      }
      throw new Error(`Unexpected fetch: ${url}`);
    });
    vi.stubGlobal("fetch", fetchMock);
    useWorkflowStore.setState({
      nodes: [
        videoNode("vid-1", true, {
          durationSeconds: 5,
          resolution: "720p",
          selectedModel: {
            provider: "newapiwg",
            modelId: "doubao-seedance-2-0-260128",
            displayName: "doubao-seedance-2-0-260128",
            capabilities: ["text-to-video", "image-to-video"],
          },
        }),
      ],
    });

    render(<GenerationComposer />);

    await waitFor(() => {
      expect(screen.getByRole("button", { name: "123 points" })).toBeInTheDocument();
    });
    const estimateCall = fetchMock.mock.calls.find(([input]) => String(input).startsWith("/api/points/estimate"));
    expect(estimateCall).toBeDefined();
    expect(fetchMock.mock.calls.some(([input]) => String(input).startsWith("/api/models"))).toBe(false);
    expect(estimateCall?.[1]).toMatchObject({
      method: "POST",
      headers: { "Content-Type": "application/json" },
    });
    expect(JSON.parse(String((estimateCall?.[1] as RequestInit).body))).toEqual({
      modelName: "doubao-seedance-2-0-260128",
      estimationType: "seedance_video",
      seedanceVideo: {
        resolution: "720p",
        aspect_ratio: "16:9",
        input_video_duration_seconds: 0,
        output_video_duration_seconds: 5,
      },
    });
  });

  it("uses PopiServer task price estimates for selected image models", async () => {
    const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
      const url = String(input);
      if (url.startsWith("/api/points/calculate-task-price")) {
        return new Response(JSON.stringify({
          success: true,
          data: {
            taskPrice: 36,
          },
        }), { status: 200 });
      }
      throw new Error(`Unexpected fetch: ${url}`);
    });
    vi.stubGlobal("fetch", fetchMock);
    useWorkflowStore.setState({
      nodes: [
        imageNode("img-1", true, {
          imageCount: 4,
          selectedModel: {
            provider: "popiserver",
            modelId: "32",
            displayName: "Popi Image",
            capabilities: ["text-to-image", "image-to-image"],
            metadata: {
              aiModelId: 32,
              aiModelCode: "gpt-image-2-all",
              aiModelCodeAlias: "gpt-image-2-all",
              type: 1,
              subType: 102,
            },
          },
        }),
      ],
    });

    render(<GenerationComposer />);

    await waitFor(() => {
      expect(screen.getByRole("button", { name: "36 points" })).toBeInTheDocument();
    });
    const priceCall = fetchMock.mock.calls.find(([input]) => String(input).startsWith("/api/points/calculate-task-price"));
    expect(priceCall).toBeDefined();
    expect(JSON.parse(String((priceCall?.[1] as RequestInit).body))).toMatchObject({
      type: 1,
      subType: 102,
      aiModelId: 32,
      aiModelCode: "gpt-image-2-all",
      aiModelCodeAlias: "gpt-image-2-all",
      model: "gpt-image-2-all",
      aspectRatio: "16:9",
      ratio: "16:9",
      resolution: "2K",
      width: 1280,
      height: 720,
      batchSize: 4,
    });
  });

  it("renders gateway image dimension options by schema name", () => {
    useWorkflowStore.setState({
      nodes: [
        imageNode("img-1", true, {
          selectedModel: {
            provider: "popiserver",
            modelId: "37",
            displayName: "Gateway Image",
            capabilities: ["text-to-image", "image-to-image"],
          },
          parameterSchema: [
            {
              name: "ratio",
              type: "string",
              label: "Ratio",
              options: [
                { label: "16:9", value: "16:9" },
                { label: "9:16", value: "9:16" },
              ],
              default: "16:9",
            },
            {
              name: "resolution",
              type: "string",
              label: "Resolution",
              options: [
                { label: "720p", value: "720" },
                { label: "4k", value: "4k" },
              ],
              default: "720",
            },
          ],
          parameters: {},
        }),
      ],
    });

    render(<GenerationComposer />);

    act(() => {
      fireEvent.change(screen.getByLabelText("Ratio"), { target: { value: "9:16" } });
      fireEvent.change(screen.getByLabelText("Resolution"), { target: { value: "4k" } });
    });

    const data = useWorkflowStore.getState().nodes[0].data as NanoBananaNodeData;
    expect(data.parameters).toMatchObject({
      ratio: "9:16",
      resolution: "4k",
    });
  });

  it("uses the Seedance estimate endpoint for PopiServer Seedance models", async () => {
    const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
      const url = String(input);
      if (url.startsWith("/api/points/estimate")) {
        return new Response(JSON.stringify({
          success: true,
          data: {
            estimated_points: 88,
          },
        }), { status: 200 });
      }
      throw new Error(`Unexpected fetch: ${url}`);
    });
    vi.stubGlobal("fetch", fetchMock);
    useWorkflowStore.setState({
      nodes: [
        videoNode("vid-1", true, {
          durationSeconds: 8,
          resolution: "1080p",
          selectedModel: {
            provider: "popiserver",
            modelId: "51",
            displayName: "Seedance Pro",
            capabilities: ["text-to-video", "image-to-video"],
            metadata: {
              aiModelId: 51,
              aiModelCode: "doubao-seedance-2-0-260128",
              aiModelCodeAlias: "seedance-video-pro",
              type: 2,
              subType: 203,
            },
          },
        }),
      ],
    });

    render(<GenerationComposer />);

    await waitFor(() => {
      expect(screen.getByRole("button", { name: "88 points" })).toBeInTheDocument();
    });
    expect(fetchMock.mock.calls.some(([input]) => String(input).startsWith("/api/points/calculate-task-price"))).toBe(false);
    const estimateCall = fetchMock.mock.calls.find(([input]) => String(input).startsWith("/api/points/estimate"));
    expect(JSON.parse(String((estimateCall?.[1] as RequestInit).body))).toMatchObject({
      modelName: "seedance-video-pro",
      estimationType: "seedance_video",
      seedanceVideo: {
        resolution: "1080p",
        aspect_ratio: "16:9",
        input_video_duration_seconds: 0,
        output_video_duration_seconds: 8,
      },
    });
  });

  it("does not show PopiServer points when the model cannot be priced", async () => {
    const fetchMock = vi.fn();
    vi.stubGlobal("fetch", fetchMock);
    useWorkflowStore.setState({
      nodes: [
        imageNode("img-1", true, {
          selectedModel: {
            provider: "popiserver",
            modelId: "",
            displayName: "Select model",
            capabilities: ["text-to-image", "image-to-image"],
          },
        }),
      ],
    });

    render(<GenerationComposer />);

    expect(screen.queryByRole("button", { name: /points/ })).not.toBeInTheDocument();
    expect(fetchMock).not.toHaveBeenCalled();
  });

  it("hydrates NewApiWG point cost for stored models without pricing", async () => {
    vi.stubGlobal("fetch", vi.fn(async () => new Response(JSON.stringify({
      success: true,
      models: [
        {
          id: "gpt-image-2-all",
          name: "gpt-image-2-all",
          description: null,
          provider: "newapiwg",
          capabilities: ["text-to-image", "image-to-image"],
          pricing: { type: "per-run", amount: 3, currency: "points" },
        },
      ],
    }), { status: 200 })));
    useWorkflowStore.setState({
      nodes: [
        imageNode("img-1", true, {
          selectedModel: {
            provider: "newapiwg",
            modelId: "gpt-image-2-all",
            displayName: "gpt-image-2-all",
            capabilities: ["text-to-image", "image-to-image"],
          },
        }),
      ],
    });

    render(<GenerationComposer />);

    await waitFor(() => {
      expect(screen.getByRole("button", { name: "3 points" })).toBeInTheDocument();
    });
    expect((useWorkflowStore.getState().nodes[0].data as NanoBananaNodeData).selectedModel?.pricing)
      .toEqual({ type: "per-run", amount: 3 });
  });

  it("submits from the collapsed generate button without expanding", async () => {
    useWorkflowStore.setState({ nodes: [imageNode("img-1", true, { inputPrompt: "visible prompt" })] });
    render(<GenerationComposer />);

    fireEvent.click(screen.getByLabelText("Collapse composer"));
    fireEvent.click(screen.getByLabelText("Generate"));

    await waitFor(() => {
      expect(useWorkflowStore.getState().regenerateNode).toHaveBeenCalledWith("img-1");
    });
    expect(screen.queryByDisplayValue("visible prompt")).not.toBeInTheDocument();
  });

  it("uses the video model filter and reruns the selected video node", async () => {
    useWorkflowStore.setState({ nodes: [videoNode("vid-1", true)] });
    render(<GenerationComposer />);

    fireEvent.click(screen.getByLabelText("Select model"));
    expect(screen.getByTestId("model-dialog")).toHaveAttribute("data-filter", "video");

    const prompt = screen.getByPlaceholderText(/Describe what you want to generate/) as HTMLTextAreaElement;
    fireEvent.change(prompt, { target: { value: "edited video prompt" } });
    fireEvent.change(screen.getByLabelText("Duration"), { target: { value: "8" } });
    fireEvent.change(screen.getByLabelText("Resolution"), { target: { value: "1080p" } });
    fireEvent.click(screen.getByRole("switch", { name: "Sound" }));
    fireEvent.click(screen.getByLabelText("Generate"));

    await waitFor(() => {
      expect(useWorkflowStore.getState().regenerateNode).toHaveBeenCalledWith("vid-1");
    });
    const data = useWorkflowStore.getState().nodes[0].data as GenerateVideoNodeData;
    expect(data.inputPrompt).toBe("edited video prompt");
    expect(data.durationSeconds).toBe(8);
    expect(data.resolution).toBe("1080p");
    expect(data.soundEnabled).toBe(false);
  });

  it("writes selected video quick settings to the node immediately", () => {
    useWorkflowStore.setState({
      nodes: [videoNode("vid-1", true, {
        durationSeconds: 6,
        resolution: "720p",
        soundEnabled: true,
      })],
    });
    render(<GenerationComposer />);

    fireEvent.change(screen.getByLabelText("Duration"), { target: { value: "4" } });
    fireEvent.change(screen.getByLabelText("Resolution"), { target: { value: "1080p" } });
    fireEvent.click(screen.getByRole("switch", { name: "Sound" }));

    const data = useWorkflowStore.getState().nodes[0].data as GenerateVideoNodeData;
    expect(data.durationSeconds).toBe(4);
    expect(data.resolution).toBe("1080p");
    expect(data.soundEnabled).toBe(false);
  });

  it("renders gateway video dimension options with raw values", () => {
    useWorkflowStore.setState({
      nodes: [
        videoNode("vid-1", true, {
          selectedModel: {
            provider: "popiserver",
            modelId: "38",
            displayName: "Gateway Video",
            capabilities: ["text-to-video", "image-to-video"],
          },
          parameterSchema: [
            {
              name: "ratio",
              type: "string",
              label: "Ratio",
              options: [
                { label: "16:9", value: "16:9" },
                { label: "9:16", value: "9:16" },
              ],
              default: "16:9",
            },
            {
              name: "videoRatio",
              type: "integer",
              label: "Video ratio",
              options: [
                { label: "10-10", value: 10 },
                { label: "20-20", value: 20 },
              ],
              default: 10,
            },
            {
              name: "resolution",
              type: "string",
              label: "Resolution",
              options: [
                { label: "720p", value: "720" },
                { label: "1024p", value: "1024" },
              ],
              default: "720",
            },
            {
              name: "duration",
              type: "integer",
              label: "Duration",
              options: [
                { label: "5", value: 5 },
                { label: "4", value: 4 },
              ],
              default: 5,
            },
          ],
          parameters: {},
        }),
      ],
    });

    render(<GenerationComposer />);

    act(() => {
      fireEvent.change(screen.getByLabelText("Ratio"), { target: { value: "9:16" } });
      fireEvent.change(screen.getByLabelText("Video ratio"), { target: { value: "20" } });
      fireEvent.change(screen.getByLabelText("Resolution"), { target: { value: "1024" } });
      fireEvent.change(screen.getByLabelText("Duration"), { target: { value: "4" } });
    });

    const data = useWorkflowStore.getState().nodes[0].data as GenerateVideoNodeData;
    expect(data.parameters).toEqual({
      ratio: "9:16",
      videoRatio: 20,
      resolution: "1024",
      duration: 4,
    });
    expect(data.durationSeconds).toBe(4);
  });

  it("defaults gateway video dimension quick settings to the first schema option", async () => {
    useWorkflowStore.setState({
      nodes: [videoNode("vid-1", true, { durationSeconds: 6 })],
    });
    render(<GenerationComposer />);

    expect(screen.getByLabelText("Duration")).toHaveValue("4");
    await waitFor(() => {
      expect((useWorkflowStore.getState().nodes[0].data as GenerateVideoNodeData).parameters).toMatchObject({
        duration: "4",
        resolution: "720p",
      });
    });
  });

  it("uses the audio model filter and reruns the selected audio node", async () => {
    useWorkflowStore.setState({ nodes: [audioNode("aud-1", true)] });
    render(<GenerationComposer />);

    expect(screen.getByText("ElevenLabs Turbo v2.5")).toBeInTheDocument();
    fireEvent.click(screen.getByLabelText("Select model"));
    expect(screen.getByTestId("model-dialog")).toHaveAttribute("data-filter", "audio");
    expect(screen.getByTestId("model-dialog")).toHaveAttribute("data-title", "Choose audio model");

    const prompt = screen.getByPlaceholderText(/Describe what you want to generate/) as HTMLTextAreaElement;
    fireEvent.change(prompt, { target: { value: "edited audio prompt" } });
    fireEvent.click(screen.getByLabelText("Generate"));

    await waitFor(() => {
      expect(useWorkflowStore.getState().regenerateNode).toHaveBeenCalledWith("aud-1");
    });
    expect((useWorkflowStore.getState().nodes[0].data as GenerateAudioNodeData).inputPrompt).toBe("edited audio prompt");
  });

  it("does not render a single-node editor for unsupported selected nodes", () => {
    useWorkflowStore.setState({ nodes: [promptNode("prompt-1", "hello", true)] });
    render(<GenerationComposer />);

    expect(screen.queryByTestId("node-inline-composer")).not.toBeInTheDocument();
    expect(screen.queryByLabelText("Generate")).not.toBeInTheDocument();
  });

  it("shows selected video stitch node content and reruns it without a prompt", async () => {
    useWorkflowStore.setState({
      nodes: [
        videoNode("vid-1", false, { outputVideo: "data:video/mp4;base64,one" }),
        videoNode("vid-2", false, { outputVideo: "data:video/mp4;base64,two" }),
        videoStitchNode("stitch-1", true, {
          clipOrder: ["edge-vid-2-stitch-1-video-1", "edge-vid-1-stitch-1-video-0"],
        }),
      ],
      edges: [
        videoEdge("vid-1", "stitch-1", 0),
        videoEdge("vid-2", "stitch-1", 1),
      ],
    });
    render(<GenerationComposer />);

    expect(screen.getByText("Video Stitch")).toBeInTheDocument();
    expect(screen.getByText("2 video clips")).toBeInTheDocument();
    expect(screen.getByText("Video inputs")).toBeInTheDocument();
    expect(screen.getByText("Clip 1")).toBeInTheDocument();
    expect(screen.queryByPlaceholderText(/Describe what you want to generate/)).not.toBeInTheDocument();

    fireEvent.click(screen.getByLabelText("Generate"));

    await waitFor(() => {
      expect(useWorkflowStore.getState().regenerateNode).toHaveBeenCalledWith("stitch-1");
    });
  });

  it("requires at least two connected video clips before running a video stitch node", () => {
    useWorkflowStore.setState({
      nodes: [
        videoNode("vid-1", false, { outputVideo: "data:video/mp4;base64,one" }),
        videoStitchNode("stitch-1", true),
      ],
      edges: [videoEdge("vid-1", "stitch-1", 0)],
    });
    render(<GenerationComposer />);

    expect(screen.getByText("Connect at least 2 video clips to stitch.")).toBeInTheDocument();
    expect(screen.getByLabelText("Generate")).toBeDisabled();
  });

  it("shows selected ease curve node content and reruns it without a prompt", async () => {
    useWorkflowStore.setState({
      nodes: [
        videoNode("vid-1", false, { outputVideo: "data:video/mp4;base64,one" }),
        easeCurveNode("ease-1", true),
      ],
      edges: [videoEdge("vid-1", "ease-1", 0, "video")],
    });
    render(<GenerationComposer />);

    expect(screen.getByText("Ease Curve")).toBeInTheDocument();
    expect(screen.getByText("Video connected")).toBeInTheDocument();
    expect(screen.getByText("Curve settings")).toBeInTheDocument();
    expect(screen.getByText("easeInOutSine")).toBeInTheDocument();
    expect(screen.queryByPlaceholderText(/Describe what you want to generate/)).not.toBeInTheDocument();

    fireEvent.click(screen.getByLabelText("Generate"));

    await waitFor(() => {
      expect(useWorkflowStore.getState().regenerateNode).toHaveBeenCalledWith("ease-1");
    });
  });

  it("requires a connected video before running an ease curve node", () => {
    useWorkflowStore.setState({ nodes: [easeCurveNode("ease-1", true)] });
    render(<GenerationComposer />);

    expect(screen.getAllByText("Connect a video input to apply ease curve.").length).toBeGreaterThan(0);
    expect(screen.getByLabelText("Generate")).toBeDisabled();
  });

  it("renders uploaded reference images in order instead of stacking them", async () => {
    useWorkflowStore.setState({ nodes: [imageNode("img-1", true)] });
    render(<GenerationComposer />);

    const firstImage = new File(["first-reference"], "first.png", { type: "image/png" });
    const secondImage = new File(["second-reference"], "second.png", { type: "image/png" });
    fireEvent.change(screen.getByLabelText("Upload reference image"), {
      target: { files: [firstImage, secondImage] },
    });

    await waitFor(() => {
      expect(screen.getByTitle("Reference image 1")).toBeInTheDocument();
      expect(screen.getByTitle("Reference image 2")).toBeInTheDocument();
    });

    const removeButtons = screen.getAllByLabelText(/Remove reference image/);
    expect(removeButtons).toHaveLength(2);

    fireEvent.click(screen.getByLabelText("Remove reference image 1"));
    await waitFor(() => {
      expect(screen.queryByTitle("Reference image 2")).not.toBeInTheDocument();
      expect(screen.getByTitle("Reference image 1")).toBeInTheDocument();
    });
  });

  it("opens a reference image preview from its thumbnail", async () => {
    useWorkflowStore.setState({ nodes: [imageNode("img-1", true)] });
    render(<GenerationComposer />);

    const imageFile = new File(["preview-reference"], "preview.png", { type: "image/png" });
    fireEvent.change(screen.getByLabelText("Upload reference image"), {
      target: { files: [imageFile] },
    });

    await waitFor(() => {
      expect(screen.getByTitle("Reference image 1")).toBeInTheDocument();
    });

    fireEvent.click(screen.getByTitle("Reference image 1"));

    const dialog = screen.getByRole("dialog", { name: "Reference image 1" });
    expect(within(dialog).getByAltText("Reference image 1")).toHaveAttribute(
      "src",
      expect.stringMatching(/^data:image\/png;base64,/)
    );

    fireEvent.keyDown(window, { key: "Escape" });
    await waitFor(() => {
      expect(screen.queryByRole("dialog", { name: "Reference image 1" })).not.toBeInTheDocument();
    });
  });

  it("keeps upstream text authoritative without overwriting the local fallback prompt", async () => {
    useWorkflowStore.setState({
      nodes: [
        promptNode("prompt-1", "connected prompt"),
        imageNode("img-1", true, { inputPrompt: "fallback prompt" }),
      ],
      edges: [textEdge("prompt-1", "img-1")],
    });
    render(<GenerationComposer />);

    const prompt = screen.getByDisplayValue("connected prompt") as HTMLTextAreaElement;
    expect(prompt).toHaveAttribute("readonly");
    fireEvent.click(screen.getByLabelText("Generate"));

    await waitFor(() => {
      expect(useWorkflowStore.getState().regenerateNode).toHaveBeenCalledWith("img-1");
    });
    expect((useWorkflowStore.getState().nodes[1].data as NanoBananaNodeData).inputPrompt).toBe("fallback prompt");
  });

  it("autosaves a dirty node draft before loading another selected node", async () => {
    const first = imageNode("img-1", true, { inputPrompt: "first" });
    const second = imageNode("img-2", false, { inputPrompt: "second" });
    useWorkflowStore.setState({ nodes: [first, second] });
    render(<GenerationComposer />);

    fireEvent.change(screen.getByDisplayValue("first"), {
      target: { value: "edited first" },
    });

    act(() => {
      useWorkflowStore.setState({
        nodes: [
          { ...first, selected: false },
          { ...second, selected: true },
        ],
      });
    });

    await waitFor(() => {
      expect(screen.getByDisplayValue("second")).toBeInTheDocument();
    });
    const nodes = useWorkflowStore.getState().nodes;
    expect((nodes[0].data as NanoBananaNodeData).inputPrompt).toBe("edited first");
  });
});
