import { describe, it, expect, beforeEach, vi } from "vitest";
import { act } from "react";
import { render, screen } from "@testing-library/react";
import { postAgentEnvelopeToWindow } from "@/canvas-protocol/scripted-agent.mock";
import { useEditorStore } from "@/store/editor-store";
import { FrameNode, type FrameNodeData } from "./frame-node";

const ADAPTER_ORIGIN = "https://adapter.veect.test:5199";
const EVIL_ORIGIN = "https://evil.example.com";

const data: FrameNodeData = {
  frame: {
    name: "Button",
    componentId: "button",
    adapterOrigin: ADAPTER_ORIGIN,
    width: 640,
    height: 480,
  },
};

function getIframe(): HTMLIFrameElement {
  return screen.getByTitle("Button frame") as HTMLIFrameElement;
}

function spyOnAgentWindow() {
  const contentWindow = getIframe().contentWindow;
  if (contentWindow === null) throw new Error("iframe has no contentWindow");
  return vi.spyOn(contentWindow, "postMessage");
}

function agentSays(message: Parameters<typeof postAgentEnvelopeToWindow>[0]) {
  act(() => postAgentEnvelopeToWindow(message, ADAPTER_ORIGIN));
}

beforeEach(() => {
  useEditorStore.setState({ registry: [], selectedNodeId: null });
});

describe("FrameNode — cold start", () => {
  it("hosts the adapter origin in a sandboxed iframe with a drag-handle tab", () => {
    render(<FrameNode data={data} />);
    const iframe = getIframe();
    expect(iframe).toHaveAttribute("src", ADAPTER_ORIGIN);
    expect(iframe).toHaveAttribute(
      "sandbox",
      "allow-scripts allow-same-origin",
    );
    expect(screen.getByTitle("Drag to move")).toHaveClass("frame-drag-handle");
    expect(screen.getByText("Button")).toBeInTheDocument();
  });

  it('shows the explicit "Starting preview…" skeleton until runtime.ready (ADR-028)', () => {
    render(<FrameNode data={data} />);
    expect(screen.getByText("Starting preview…")).toBeInTheDocument();
  });

  it("sends nothing before runtime.ready — queue-drain semantics", () => {
    render(<FrameNode data={data} />);
    const post = spyOnAgentWindow();
    expect(post).not.toHaveBeenCalled();
  });
});

describe("FrameNode — runtime.ready", () => {
  it("requests a single Element via render-frame (default props) then geometry, pinned to the adapter origin", () => {
    render(<FrameNode data={data} />);
    const post = spyOnAgentWindow();

    agentSays({ type: "runtime.ready" });

    expect(post).toHaveBeenCalledTimes(2);
    expect(post).toHaveBeenNthCalledWith(
      1,
      {
        protocolVersion: "0",
        message: { type: "render-frame", componentId: "button" },
      },
      ADAPTER_ORIGIN,
    );
    expect(post).toHaveBeenNthCalledWith(
      2,
      {
        protocolVersion: "0",
        message: { type: "geometry.request" },
      },
      ADAPTER_ORIGIN,
    );
    expect(screen.queryByText("Starting preview…")).not.toBeInTheDocument();
  });

  it("ignores runtime.ready from a wrong origin — still starting, nothing sent", () => {
    render(<FrameNode data={data} />);
    const post = spyOnAgentWindow();

    act(() =>
      postAgentEnvelopeToWindow({ type: "runtime.ready" }, EVIL_ORIGIN),
    );

    expect(post).not.toHaveBeenCalled();
    expect(screen.getByText("Starting preview…")).toBeInTheDocument();
  });
});

describe("FrameNode — selection round-trip from reported geometry", () => {
  it("click report selects in the store and the overlay renders at the reported rect", () => {
    render(<FrameNode data={data} />);
    agentSays({ type: "runtime.ready" });
    agentSays({
      type: "geometry.report",
      targets: [
        { nodeId: "el-1", rect: { x: 16, y: 32, width: 240, height: 56 } },
      ],
    });

    expect(screen.queryByTestId("selection-overlay")).not.toBeInTheDocument();

    agentSays({ type: "click.target", nodeId: "el-1" });

    expect(useEditorStore.getState().selectedNodeId).toBe("el-1");
    expect(screen.getByTestId("selection-overlay")).toHaveStyle({
      left: "16px",
      top: "32px",
      width: "240px",
      height: "56px",
    });
  });

  it("background click deselects and hides the overlay", () => {
    render(<FrameNode data={data} />);
    agentSays({ type: "runtime.ready" });
    agentSays({
      type: "geometry.report",
      targets: [
        { nodeId: "el-1", rect: { x: 0, y: 0, width: 10, height: 10 } },
      ],
    });
    agentSays({ type: "click.target", nodeId: "el-1" });
    expect(screen.getByTestId("selection-overlay")).toBeInTheDocument();

    agentSays({ type: "click.target", nodeId: null });

    expect(useEditorStore.getState().selectedNodeId).toBeNull();
    expect(screen.queryByTestId("selection-overlay")).not.toBeInTheDocument();
  });

  it("no overlay when the selected node has no reported geometry yet", () => {
    render(<FrameNode data={data} />);
    agentSays({ type: "runtime.ready" });
    agentSays({ type: "click.target", nodeId: "el-unmeasured" });
    expect(useEditorStore.getState().selectedNodeId).toBe("el-unmeasured");
    expect(screen.queryByTestId("selection-overlay")).not.toBeInTheDocument();
  });
});

describe("FrameNode — drag path stays off the protocol", () => {
  it("rerendering (as frame drag does) produces zero protocol traffic", () => {
    const { rerender } = render(<FrameNode data={data} />);
    const post = spyOnAgentWindow();
    agentSays({ type: "runtime.ready" });
    expect(post).toHaveBeenCalledTimes(2);

    // React Flow drag rerenders the node with fresh props; data values
    // are unchanged, so the canvas client must not be recreated and no
    // message may be sent (<16 ms/frame budget, ADR-028).
    rerender(<FrameNode data={{ frame: { ...data.frame } }} />);
    rerender(<FrameNode data={{ frame: { ...data.frame } }} />);

    expect(post).toHaveBeenCalledTimes(2);
  });

  it("stops listening on unmount — late agent messages no longer reach the store", () => {
    const { unmount } = render(<FrameNode data={data} />);
    agentSays({ type: "runtime.ready" });
    unmount();

    agentSays({ type: "click.target", nodeId: "el-late" });

    expect(useEditorStore.getState().selectedNodeId).toBeNull();
  });
});

describe("FrameNode — defaults", () => {
  it("falls back to the 800x600 default viewport", () => {
    render(
      <FrameNode
        data={{
          frame: {
            name: "Card",
            componentId: "card",
            adapterOrigin: ADAPTER_ORIGIN,
          },
        }}
      />,
    );
    expect(screen.getByText(/800 × 600/)).toBeInTheDocument();
  });
});
