import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";

vi.mock("./components/ui/select", async () => {
  const React = await import("react");

  function MockSelectItem(_props: { value: string; children: React.ReactNode }) {
    return null;
  }

  function collectItems(children: React.ReactNode): Array<{ value: string; label: string }> {
    const items: Array<{ value: string; label: string }> = [];

    React.Children.forEach(children, (child) => {
      if (!React.isValidElement(child)) return;

      if (child.type === MockSelectItem) {
        items.push({
          value: child.props.value as string,
          label: String(child.props.children),
        });
        return;
      }

      if (child.props && "children" in child.props) {
        items.push(...collectItems(child.props.children));
      }
    });

    return items;
  }

  const SelectContext = React.createContext<{
    value?: string;
    onValueChange?: (value: string) => void;
    items: Array<{ value: string; label: string }>;
  } | null>(null);

  function Select({
    value,
    onValueChange,
    children,
  }: {
    value?: string;
    onValueChange?: (value: string) => void;
    children: React.ReactNode;
  }) {
    const items = collectItems(children);

    return (
      <SelectContext.Provider value={{ value, onValueChange, items }}>
        {children}
      </SelectContext.Provider>
    );
  }

  function SelectTrigger({ className, ...props }: React.SelectHTMLAttributes<HTMLSelectElement>) {
    const context = React.useContext(SelectContext);

    return (
      <select
        {...props}
        className={className}
        value={context?.value ?? ""}
        onChange={(event) => context?.onValueChange?.(event.target.value)}
      >
        {context?.items.map((item) => (
          <option key={item.value} value={item.value}>
            {item.label}
          </option>
        ))}
      </select>
    );
  }

  return {
    Select,
    SelectContent: ({ children }: { children: React.ReactNode }) => <>{children}</>,
    SelectGroup: ({ children }: { children: React.ReactNode }) => <>{children}</>,
    SelectItem: MockSelectItem,
    SelectLabel: () => null,
    SelectSeparator: () => null,
    SelectTrigger,
    SelectValue: () => null,
  };
});

import {
  App,
  attachmentForMessage,
  ChatPage,
  describeImageAttachmentForPrompt,
  describeRenderedPagesAsText,
} from "./App";
import type { StatusPayload } from "./features/app-shell/lib/status-types";

function buildProps(
  overrides: Partial<Parameters<typeof ChatPage>[0]> = {},
): Parameters<typeof ChatPage>[0] {
  return {
    status: {
      node_id: "node-1",
      token: "invite-token",
      node_state: "serving",
      node_status: "Serving",
      is_host: true,
      is_client: false,
      llama_ready: true,
      api_port: 9337,
      model_name: "model-a",
      model_size_gb: 1,
      inflight_requests: 0,
      my_vram_gb: 12,
      peers: [],
    },
    inviteToken: "invite-token",
    isPublicMesh: false,
    isFlyHosted: false,
    inflightRequests: 0,
    warmModels: ["model-a"],
    meshModelByName: {},
    modelStatsByName: {},
    selectedModel: "model-a",
    setSelectedModel: vi.fn(),
    selectedModelNodeCount: 1,
    selectedModelVramGb: 12,
    selectedModelAudio: true,
    selectedModelMultimodal: true,
    composerError: null,
    setComposerError: vi.fn(),
    attachmentSendIssue: null,
    attachmentPreparationMessage: null,
    pendingAttachments: [],
    setPendingAttachments: vi.fn(),
    conversations: [
      {
        id: "chat-1",
        title: "Chat 1",
        createdAt: Date.now(),
        updatedAt: Date.now(),
        messages: [],
      },
    ],
    activeConversationId: "chat-1",
    onConversationCreate: vi.fn(),
    onConversationSelect: vi.fn(),
    onConversationRename: vi.fn(),
    onConversationDelete: vi.fn(),
    onConversationsClear: vi.fn(),
    messages: [],
    reasoningOpen: {},
    setReasoningOpen: vi.fn(),
    chatScrollRef: { current: null },
    input: "",
    setInput: vi.fn(),
    isSending: false,
    queuedText: null,
    canChat: true,
    canRegenerate: false,
    onStop: vi.fn(),
    onRegenerate: vi.fn(),
    onSubmit: vi.fn(),
    ...overrides,
  };
}

const statusTemplate: StatusPayload = {
  version: "1.0.0",
  latest_version: null,
  node_id: "node-1",
  token: "token-123",
  node_state: "serving",
  node_status: "Serving",
  is_host: true,
  is_client: false,
  llama_ready: true,
  model_name: "model-a",
  models: ["model-a"],
  available_models: ["model-a"],
  requested_models: [],
  serving_models: ["model-a"],
  hosted_models: ["model-a"],
  api_port: 9337,
  my_vram_gb: 16,
  model_size_gb: 8,
  mesh_name: "test-mesh",
  peers: [],
  inflight_requests: 0,
  nostr_discovery: false,
  publication_state: "private" as const,
  my_hostname: "host.local",
  gpus: [],
};

let statusPayload = createStatusPayload();
let modelsPayload = { mesh_models: [] as Array<Record<string, unknown>> };
const mockFetch = vi.fn();

function createStatusPayload() {
  return {
    ...statusTemplate,
    peers: [] as typeof statusTemplate.peers,
    models: [] as typeof statusTemplate.models,
    available_models: [] as typeof statusTemplate.available_models,
    requested_models: [] as typeof statusTemplate.requested_models,
    serving_models: [...(statusTemplate.serving_models ?? [])],
    hosted_models: [...(statusTemplate.hosted_models ?? [])],
    gpus: [] as typeof statusTemplate.gpus,
  };
}

function createResponse(body: unknown) {
  return new Response(JSON.stringify(body), {
    status: 200,
    headers: { "Content-Type": "application/json" },
  });
}

function getRequestUrl(input: RequestInfo | URL) {
  if (typeof input === "string") return input;
  if (input instanceof URL) return input.href;
  return input.url;
}

function setupFetchMock() {
  mockFetch.mockImplementation((input: RequestInfo | URL) => {
    const url = getRequestUrl(input);
    if (url.endsWith("/api/status")) {
      return Promise.resolve(createResponse(statusPayload));
    }
    if (url.endsWith("/api/models")) {
      return Promise.resolve(createResponse(modelsPayload));
    }
    return Promise.resolve(createResponse({}));
  });
  globalThis.fetch = mockFetch as typeof fetch;
}

function setPath(path: string) {
  window.history.replaceState({}, "", path);
}

class MockEventSource {
  onopen: ((event: Event) => void) | null = null;
  onmessage: ((event: MessageEvent) => void) | null = null;
  onerror: ((event: Event) => void) | null = null;
  readyState = 0;
  withCredentials = false;

  constructor(public url: string) {
    queueMicrotask(() => {
      this.onopen?.(new Event("open"));
    });
  }

  close() {}

  addEventListener() {}

  removeEventListener() {}

  dispatchEvent() {
    return false;
  }
}

beforeAll(() => {
  const makeMatchMedia = () => ({
    matches: false,
    media: "",
    onchange: null,
    addEventListener: vi.fn(),
    removeEventListener: vi.fn(),
    addListener: vi.fn(),
    removeListener: vi.fn(),
    dispatchEvent: vi.fn(),
  });

  Object.defineProperty(window, "matchMedia", {
    configurable: true,
    writable: true,
    value: () => makeMatchMedia(),
  });

  Object.defineProperty(navigator, "clipboard", {
    configurable: true,
    value: { writeText: vi.fn().mockResolvedValue(undefined) },
  });

  Object.defineProperty(HTMLElement.prototype, "hasPointerCapture", {
    configurable: true,
    value: vi.fn(() => false),
  });
  Object.defineProperty(HTMLElement.prototype, "setPointerCapture", {
    configurable: true,
    value: vi.fn(),
  });
  Object.defineProperty(HTMLElement.prototype, "releasePointerCapture", {
    configurable: true,
    value: vi.fn(),
  });
});

beforeEach(() => {
  statusPayload = createStatusPayload();
  modelsPayload = { mesh_models: [] };
  setupFetchMock();
  Object.defineProperty(window, "EventSource", {
    configurable: true,
    writable: true,
    value: MockEventSource,
  });
  setPath("/");
});

afterEach(() => {
  vi.resetAllMocks();
  setPath("/");
});

describe("ChatPage", () => {
  it("allows attachment-only sends and renders attachment controls", () => {
    render(
      <ChatPage
        {...buildProps({
          pendingAttachments: [
            {
              id: "att-1",
              kind: "file",
              dataUrl: "data:text/plain;base64,aGVsbG8=",
              mimeType: "text/plain",
              fileName: "hello.txt",
              status: "pending",
            },
          ],
        })}
      />,
    );

    expect(screen.getByTestId("chat-file-input")).toBeInTheDocument();
    expect(screen.getByTestId("chat-image-input")).toBeInTheDocument();
    expect(screen.getByTestId("chat-audio-input")).toBeInTheDocument();
    expect(screen.getByTestId("chat-send")).toBeEnabled();
    expect(screen.getByText("hello.txt")).toBeInTheDocument();
  });

  it("renders attachment policy errors", () => {
    render(
      <ChatPage
        {...buildProps({
          attachmentSendIssue:
            "Selected model does not support the attached media. Choose a compatible model or remove the attachment.",
        })}
      />,
    );

    expect(screen.getByTestId("composer-error")).toHaveTextContent(
      "Selected model does not support the attached media.",
    );
  });

  it("shows attachment preparation progress and disables send", () => {
    render(
      <ChatPage
        {...buildProps({
          attachmentPreparationMessage: "Preparing PDF in browser…",
          pendingAttachments: [
            {
              id: "att-pdf",
              kind: "file",
              dataUrl: "data:application/pdf;base64,abc",
              mimeType: "application/pdf",
              fileName: "scan.pdf",
              status: "uploading",
            },
          ],
        })}
      />,
    );

    expect(screen.getByText("Preparing PDF in browser…")).toBeInTheDocument();
    expect(screen.getByTestId("chat-send")).toBeDisabled();
  });

  it("shows failed image-description state with retry affordance", () => {
    render(
      <ChatPage
        {...buildProps({
          pendingAttachments: [
            {
              id: "att-image-failed",
              kind: "image",
              dataUrl: "data:image/png;base64,abc",
              mimeType: "image/png",
              fileName: "legacy.png",
              status: "failed",
              extractionSummary: "Image description failed — retry or send placeholder text",
              error: "Image description failed: model init failed",
            },
          ],
        })}
      />,
    );

    expect(screen.getByText("Retry")).toBeInTheDocument();
    expect(
      screen.getByText("Image description failed: model init failed"),
    ).toBeInTheDocument();
    expect(
      screen.getByText("Image description failed — retry or send placeholder text"),
    ).toBeInTheDocument();
  });

  it("shows Queue button label and calls onSubmit when isSending=true", () => {
    const onSubmit = vi.fn();
    render(
      <ChatPage
        {...buildProps({ isSending: true, input: "next message", onSubmit })}
      />,
    );

    const btn = screen.getByTestId("chat-send");
    expect(btn).toHaveTextContent("Queue");
    btn.click();
    expect(onSubmit).toHaveBeenCalled();
  });

  it("renders queued bubble with the queued text when queuedText is set", () => {
    render(
      <ChatPage
        {...buildProps({
          isSending: true,
          queuedText: "queued message",
          messages: [
            {
              id: "msg-1",
              role: "user" as const,
              content: "hello",
            },
          ],
        })}
      />,
    );

    expect(screen.getByText("Queued")).toBeInTheDocument();
    expect(screen.getByText("queued message")).toBeInTheDocument();
  });

  it("shows Send button and no queued bubble when not sending", () => {
    render(<ChatPage {...buildProps({ isSending: false, queuedText: null })} />);

    expect(screen.getByTestId("chat-send")).toHaveTextContent("Send");
    expect(screen.queryByText("Queued")).not.toBeInTheDocument();
  });

  it("calls onSubmit for attachment-only queue (empty text, pending attachment, isSending=true)", () => {
    const onSubmit = vi.fn();
    render(
      <ChatPage
        {...buildProps({
          isSending: true,
          input: "",
          queuedText: "",
          pendingAttachments: [
            {
              id: "att-2",
              kind: "image",
              dataUrl: "data:image/png;base64,abc",
              mimeType: "image/png",
              fileName: "photo.png",
              status: "pending",
            },
          ],
          onSubmit,
        })}
      />,
    );

    const btn = screen.getByTestId("chat-send");
    expect(btn).toHaveTextContent("Queue");
    btn.click();
    expect(onSubmit).toHaveBeenCalled();
  });
});

describe("App routing and status", () => {
  it("desktop unknown path fallback resolves to dashboard behavior", async () => {
    setPath("/unknown-path");
    render(<App />);

    const networkLink = await screen.findByRole("link", { name: "Network" });
    expect(networkLink).toHaveAttribute("aria-current", "page");
    await waitFor(() => expect(window.location.pathname).toBe("/dashboard"));
    expect(screen.queryByRole("button", { name: /New chat/i })).not.toBeInTheDocument();
  });

  it("mobile unknown path fallback also syncs dashboard state", async () => {
    const previousInnerWidth = window.innerWidth;
    Object.defineProperty(window, "innerWidth", {
      configurable: true,
      writable: true,
      value: 640,
    });
    setPath("/unknown-path");

    try {
      render(<App />);

      const networkLink = await screen.findByRole("link", { name: "Network" });
      expect(networkLink).toHaveAttribute("aria-current", "page");
      await waitFor(() => expect(window.location.pathname).toBe("/dashboard"));
      expect(screen.queryByRole("button", { name: /New chat/i })).not.toBeInTheDocument();
    } finally {
      Object.defineProperty(window, "innerWidth", {
        configurable: true,
        writable: true,
        value: previousInnerWidth,
      });
    }
  });

  it("/dashboard route renders without redirecting to /config", async () => {
    setPath("/dashboard");
    render(<App />);

    const networkLink = await screen.findByRole("link", { name: "Network" });
    expect(networkLink).toHaveAttribute("aria-current", "page");
    await waitFor(() => expect(window.location.pathname).toBe("/dashboard"));
    expect(screen.queryByRole("button", { name: /New chat/i })).not.toBeInTheDocument();
  });

  it("/chat route renders chat section content", async () => {
    setPath("/chat");
    render(<App />);

    const chatLink = await screen.findByRole("link", { name: "Chat" });
    expect(chatLink).toHaveAttribute("aria-current", "page");
    await screen.findByRole("button", { name: /New chat/i });
    await waitFor(() => expect(window.location.pathname).toBe("/chat"));
    expect(
      screen.queryByRole("link", { current: "page", name: "Network" }),
    ).not.toBeInTheDocument();
  });

  it("boots /api/status on mount and consumes status payload", async () => {
    setPath("/dashboard");
    render(<App />);

    await waitFor(() =>
      expect(mockFetch.mock.calls.some((call) => call[0] === "/api/status")).toBe(
        true,
      ),
    );
    await screen.findByText("Mesh LLM v1.0.0");
  });

  it("renders dashboard live-state labels from node_state and peer state", async () => {
    statusPayload = {
      ...createStatusPayload(),
      node_state: "loading",
      node_status: "Serving",
      is_host: false,
      llama_ready: false,
      model_name: "",
      hosted_models: [],
      serving_models: [],
      peers: [
        {
          id: "peer-standby",
          role: "Host",
          state: "standby",
          models: [],
          available_models: [],
          requested_models: [],
          serving_models: [],
          hosted_models: [],
          hosted_models_known: true,
          vram_gb: 16,
          rtt_ms: 18,
          hostname: "peer-host.local",
        },
      ],
    };

    setPath("/dashboard");
    render(<App />);

    expect((await screen.findAllByText("Loading")).length).toBeGreaterThan(0);
    expect((await screen.findAllByText("Standby")).length).toBeGreaterThan(0);
    expect(screen.getByText("Host")).toBeInTheDocument();
    expect(screen.queryAllByText("Serving")).toHaveLength(0);
  });

  it("shows model peer shares from model-serving VRAM instead of physical inventory", async () => {
    statusPayload = {
      ...createStatusPayload(),
      node_id: "6566c0f64b",
      model_name: "Hermes-2-Pro-Mistral-7B-Q4_K_M",
      models: ["Hermes-2-Pro-Mistral-7B-Q4_K_M"],
      available_models: ["Hermes-2-Pro-Mistral-7B-Q4_K_M"],
      serving_models: ["Hermes-2-Pro-Mistral-7B-Q4_K_M"],
      hosted_models: ["Hermes-2-Pro-Mistral-7B-Q4_K_M"],
      my_vram_gb: 15,
      gpus: [{ name: "RTX 6000 Ada", vram_bytes: 64 * 1024 ** 3 }],
      peers: [
        {
          id: "d0aa73bd0e",
          role: "Worker",
          state: "serving",
          models: ["Hermes-2-Pro-Mistral-7B-Q4_K_M"],
          available_models: ["Hermes-2-Pro-Mistral-7B-Q4_K_M"],
          requested_models: [],
          serving_models: ["Hermes-2-Pro-Mistral-7B-Q4_K_M"],
          hosted_models: ["Hermes-2-Pro-Mistral-7B-Q4_K_M"],
          hosted_models_known: true,
          vram_gb: 8,
          rtt_ms: null,
          gpus: [],
        },
      ],
    };
    modelsPayload = {
      mesh_models: [
        {
          name: "Hermes-2-Pro-Mistral-7B-Q4_K_M",
          status: "warm",
          node_count: 2,
          mesh_vram_gb: 23,
          size_gb: 4.1,
          source_file:
            "bartowski/Hermes-2-Pro-Mistral-7B-GGUF/Hermes-2-Pro-Mistral-7B-Q4_K_M.gguf",
        },
      ],
    };

    setPath("/dashboard");
    render(<App />);

    const modelButton = await screen.findByRole("button", {
      name: /Hermes-2-Pro-Mistral-7B/i,
    });
    fireEvent.click(modelButton);

    await screen.findByText("Active Peers");
    expect(screen.getAllByText("6566c0f64b").length).toBeGreaterThan(0);
    expect(screen.getByText("15.0 GB")).toBeInTheDocument();
    expect(screen.getByText("65%")).toBeInTheDocument();
    expect(screen.getAllByText("d0aa73bd0e").length).toBeGreaterThan(0);
    expect(screen.getAllByText("8.0 GB").length).toBeGreaterThan(0);
    expect(screen.getAllByText("35%").length).toBeGreaterThan(0);
    expect(screen.queryByText("278%")).not.toBeInTheDocument();
  });

  it("keeps client chat disabled until /api/models reports a warm model", async () => {
    statusPayload = {
      ...createStatusPayload(),
      is_client: true,
      is_host: false,
      llama_ready: false,
      model_name: "ghost-model",
      hosted_models: [],
      serving_models: [],
    };
    setPath("/chat");
    render(<App />);

    const input = await screen.findByTestId("chat-input");
    await waitFor(() =>
      expect(mockFetch.mock.calls.some((call) => call[0] === "/api/models")).toBe(
        true,
      ),
    );
    expect(input).toBeDisabled();
    expect(input).toHaveAttribute("placeholder", "Waiting for a warm model...");
    expect(screen.getByTestId("chat-send")).toBeDisabled();
  });

  it("allows chat from a stage-only node with a mesh-routable model", async () => {
    statusPayload = {
      ...createStatusPayload(),
      is_client: false,
      is_host: false,
      llama_ready: false,
      node_state: "standby",
      node_status: "Standby",
      model_name: "unsloth/Qwen3.6-35B-A3B-GGUF:UD-Q4_K_XL",
      models: ["unsloth/Qwen3.6-35B-A3B-GGUF:UD-Q4_K_XL"],
      hosted_models: [],
      serving_models: ["unsloth/Qwen3.6-35B-A3B-GGUF:UD-Q4_K_XL"],
    };
    setPath("/chat");
    render(<App />);

    const input = await screen.findByTestId("chat-input");
    await waitFor(() => expect(input).not.toBeDisabled());
    expect(input).toHaveAttribute("placeholder", "Ask me anything...");
  });


  it("ignores the global command-bar shortcut when focus is inside the chat input", async () => {
    statusPayload = createStatusPayload();
    setPath("/chat");

    render(<App />);

    const chatInput = await screen.findByTestId("chat-input");
    chatInput.focus();

    fireEvent.keyDown(chatInput, { key: "k", metaKey: true });

    expect(screen.queryByRole("dialog", { name: "Switch models" })).not.toBeInTheDocument();
    expect(chatInput).toHaveFocus();
  });
});

describe("describeRenderedPagesAsText", () => {
  it("combines page descriptions and preserves failures as placeholders", async () => {
    const onProgress = vi.fn();
    const describe = vi
      .fn<
        (dataUrl: string) => Promise<{
          combinedText: string;
          description: string;
          ocrText: string;
          objects: string[];
        }>
      >()
      .mockResolvedValueOnce({
        combinedText: "First page OCR",
        description: "First page OCR",
        ocrText: "First page OCR",
        objects: [],
      })
      .mockRejectedValueOnce(new Error("boom"))
      .mockResolvedValueOnce({
        combinedText: "",
        description: "",
        ocrText: "",
        objects: [],
      });

    const text = await describeRenderedPagesAsText(
      [
        "data:image/png;base64,one",
        "data:image/png;base64,two",
        "data:image/png;base64,three",
      ],
      { describe, onProgress },
    );

    expect(text).toContain("[Page 1]\nFirst page OCR");
    expect(text).toContain("[Page 2]\n[Unable to describe page]");
    expect(text).toContain("[Page 3]\n[Unable to describe page]");
    expect(onProgress).toHaveBeenNthCalledWith(
      1,
      "Describing scanned PDF page 1/3...",
    );
    expect(onProgress).toHaveBeenNthCalledWith(
      2,
      "Describing scanned PDF page 2/3...",
    );
    expect(onProgress).toHaveBeenNthCalledWith(
      3,
      "Describing scanned PDF page 3/3...",
    );
  });
});

describe("describeImageAttachmentForPrompt", () => {
  it("returns image text and summary on success", async () => {
    const describe = vi.fn<typeof describeImageAttachmentForPrompt extends never ? never : any>().mockResolvedValue({
      combinedText: "A cat on a chair",
      description: "A cat on a chair",
      ocrText: "",
      objects: [],
    });

    const result = await describeImageAttachmentForPrompt(
      "data:image/png;base64,abc",
      { describe },
    );

    expect(result).toEqual({
      imageDescription: "A cat on a chair",
      extractionSummary: "Described by local vision",
    });
  });

  it("returns a visible warning payload on failure", async () => {
    const describe = vi.fn().mockRejectedValue(new Error("boom"));

    const result = await describeImageAttachmentForPrompt(
      "data:image/png;base64,abc",
      { describe },
    );

    expect(result.imageDescription).toBeUndefined();
    expect(result.extractionSummary).toBe(
      "Image description failed — retry or send placeholder text",
    );
    expect(result.error).toContain("Image description failed: boom");
  });
});

describe("attachmentForMessage", () => {
  it("drops rendered page images once extracted text exists", () => {
    const attachment = attachmentForMessage({
      id: "att-pdf",
      kind: "file",
      dataUrl: "data:application/pdf;base64,abc",
      mimeType: "application/pdf",
      fileName: "scan.pdf",
      status: "pending",
      extractedText: "Recovered text",
      renderedPageImages: ["data:image/png;base64,one"],
      extractionSummary: "1 page described",
    });

    expect(attachment.extractedText).toBe("Recovered text");
    expect(attachment.renderedPageImages).toBeUndefined();
  });
});
