import { act, render, waitFor } from "@testing-library/react";
import * as React from "react";
import { describe, expect, it, vi } from "vitest";
import { Mesh, MeshStandardMaterial, type BufferGeometry } from "three";

import { encodeToolcraftModelDocument } from "../../model-import/canonical/model-document-codec";
import { createValidModelDocument } from "../../model-import/canonical/model-document-test-support";
import type { ToolcraftSourceAssetFeedback } from "../../source-assets/source-asset-types";
import type {
  ToolcraftModelPresentationLease,
  ToolcraftModelRenderBinding,
  ToolcraftModelRenderHost,
  ToolcraftModelResourceResolver,
} from "./model-render-binding";
import {
  ToolcraftModelRenderProvider,
  useToolcraftModelRenderHost,
  useToolcraftModelRenderPreparationStatus,
} from "./model-render-provider";

type TestResource = { id: number };

function createBinding() {
  let sequence = 0;
  const binding: ToolcraftModelRenderBinding<TestResource> = {
    create: vi.fn(() => ({ id: ++sequence })),
    dispose: vi.fn(),
    hitTest: vi.fn((_resource, point) => point.clientX < 100),
    renderExport: vi.fn(),
    renderPreview: vi.fn(),
    update: vi.fn(),
  };

  return binding;
}

const asset = {
  activeDocumentRef: "document:a",
  id: "model-1",
  layerId: "layer-1",
  lifecycle: "clean" as const,
  sourceTarget: "source.model",
};
const previewContext = {
  height: 200,
  host: document.createElement("div"),
  pixelRatio: 1,
  selected: false,
  width: 300,
};

function HostProbe({
  onHost,
}: {
  onHost: (host: ToolcraftModelRenderHost) => void;
}): null {
  const host = useToolcraftModelRenderHost();

  React.useEffect(() => onHost(host), [host, onHost]);
  return null;
}

function PreparationProbe(): React.JSX.Element {
  const status = useToolcraftModelRenderPreparationStatus();
  return <span data-testid="model-render-preparation">{status}</span>;
}

function createResolver(): ToolcraftModelResourceResolver {
  const bytes = encodeToolcraftModelDocument(createValidModelDocument());
  return vi.fn(async () => bytes);
}

async function renderHost(
  binding = createBinding(),
  feedback: Readonly<{
    clear?: (sourceTarget: string) => void;
    report?: (
      sourceTarget: string,
      feedback: ToolcraftSourceAssetFeedback,
    ) => void;
  }> = {},
) {
  let host: ToolcraftModelRenderHost | undefined;
  const view = render(
    <ToolcraftModelRenderProvider
      bindings={{ "source.model": binding }}
      clearPresentationFeedback={feedback.clear}
      reportPresentationFeedback={feedback.report}
      resolveResource={createResolver()}
      standardBinding={createBinding()}
    >
      <HostProbe onHost={(value) => { host = value; }} />
    </ToolcraftModelRenderProvider>,
  );
  await waitFor(() => expect(host).toBeDefined());
  return { binding, host: host!, ...view };
}

describe("ToolcraftModelRenderProvider", () => {
  it("prepares the standard renderer once and publishes observable readiness", async () => {
    let finishPreparation: (() => void) | undefined;
    const standardBinding = createBinding();
    standardBinding.preparePreview = vi.fn(
      () => new Promise<void>((resolve) => {
        finishPreparation = resolve;
      }),
    );
    standardBinding.disposePreparedPreview = vi.fn();
    let host: ToolcraftModelRenderHost | undefined;
    const view = render(
      <ToolcraftModelRenderProvider
        resolveResource={createResolver()}
        standardBinding={standardBinding}
      >
        <HostProbe onHost={(value) => { host = value; }} />
        <PreparationProbe />
      </ToolcraftModelRenderProvider>,
    );

    await waitFor(() => expect(host).toBeDefined());
    void host!.prepare({
      height: 720,
      host: document.createElement("div"),
      pixelRatio: 1,
      target: "source.model",
      width: 1280,
    });
    await waitFor(() => {
      expect(view.getByTestId("model-render-preparation").textContent).toBe(
        "preparing",
      );
      expect(finishPreparation).toBeDefined();
    });
    act(() => finishPreparation?.());
    await waitFor(() => {
      expect(view.getByTestId("model-render-preparation").textContent).toBe(
        "ready",
      );
    });
    expect(standardBinding.preparePreview).toHaveBeenCalledTimes(1);

    view.unmount();
    await waitFor(() => {
      expect(standardBinding.disposePreparedPreview).toHaveBeenCalledTimes(1);
    });
  });

  it("keeps the host active across the StrictMode effect replay", async () => {
    const binding = createBinding();
    let host: ToolcraftModelRenderHost | undefined;
    const view = render(
      <React.StrictMode>
        <ToolcraftModelRenderProvider
          bindings={{ "source.model": binding }}
          resolveResource={createResolver()}
          standardBinding={createBinding()}
        >
          <HostProbe onHost={(value) => { host = value; }} />
        </ToolcraftModelRenderProvider>
      </React.StrictMode>,
    );
    await waitFor(() => expect(host).toBeDefined());
    await Promise.resolve();

    await act(async () => {
      await host!.renderPreview(
        "strict-layer",
        { asset, phase: "clean", target: "source.model" },
        previewContext,
      );
    });
    expect(
      host!.hitTest("strict-layer", { clientX: 50, clientY: 40 }),
    ).toBe(true);

    view.unmount();
    await waitFor(() => expect(binding.dispose).toHaveBeenCalledTimes(1));
  });

  it("creates, updates, renders, and hit-tests a target resource", async () => {
    const { binding, host } = await renderHost();

    await act(async () => {
      await host.renderPreview(
        "layer-1",
        {
          asset,
          phase: "clean",
          target: "source.model",
          viewport: { height: 200, width: 300 },
        },
        previewContext,
      );
      await host.renderPreview(
        "layer-1",
        {
          asset,
          phase: "repairing",
          target: "source.model",
          viewport: { height: 200, width: 300 },
        },
        previewContext,
      );
    });

    expect(binding.create).toHaveBeenCalledTimes(1);
    expect(binding.update).toHaveBeenCalledTimes(1);
    expect(binding.renderPreview).toHaveBeenCalledTimes(2);
    expect(host.hitTest("layer-1", { clientX: 50, clientY: 40 })).toBe(true);
    expect(host.hitTest("layer-1", { clientX: 150, clientY: 40 })).toBe(false);
  });

  it("uses a separate full-opacity export resource", async () => {
    const { binding, host } = await renderHost();

    await act(async () => {
      await host.renderPreview(
        "layer-1",
        {
          asset,
          phase: "repairing",
          target: "source.model",
          viewport: { height: 200, width: 300 },
        },
        previewContext,
      );
      await host.renderExport(
        {
          asset,
          phase: "repairing",
          target: "source.model",
          viewport: { height: 1080, width: 1920 },
        },
        { height: 1080, pixelRatio: 1, width: 1920 },
      );
    });

    expect(binding.create).toHaveBeenCalledTimes(2);
    expect(binding.renderExport).toHaveBeenCalledWith(
      expect.anything(),
      expect.objectContaining({ height: 1080, width: 1920 }),
    );
    expect(binding.create).toHaveBeenLastCalledWith(
      expect.objectContaining({
        presentationOpacity: 1,
        purpose: "export",
      }),
      expect.anything(),
    );
    expect(binding.dispose).toHaveBeenCalledTimes(1);
  });

  it("shares the canonical presentation cache across preview and export leases", async () => {
    const resources: ToolcraftModelPresentationLease[] = [];
    const geometries: BufferGeometry[] = [];
    const binding: ToolcraftModelRenderBinding<
      ToolcraftModelPresentationLease
    > = {
      create: vi.fn(async (presentation, context) => {
        const lease = await context.acquirePresentation(
          presentation.canonicalDocumentRef,
          { purpose: presentation.purpose, signal: context.signal },
        );
        resources.push(lease);
        geometries.push(
          (lease.root.getObjectByName("primitive-1") as Mesh).geometry,
        );
        return lease;
      }),
      dispose: vi.fn((lease) => lease.release()),
      hitTest: vi.fn(() => false),
      renderExport: vi.fn(),
      renderPreview: vi.fn(),
      update: vi.fn(),
    };
    const resolveResource = createResolver();
    let host: ToolcraftModelRenderHost | undefined;
    const view = render(
      <ToolcraftModelRenderProvider
        bindings={{ "source.model": binding }}
        resolveResource={resolveResource}
        standardBinding={createBinding()}
      >
        <HostProbe onHost={(value) => { host = value; }} />
      </ToolcraftModelRenderProvider>,
    );
    await waitFor(() => expect(host).toBeDefined());

    await act(async () => {
      await host!.renderPreview(
        "layer-1",
        { asset, phase: "clean", target: "source.model" },
        previewContext,
      );
      await host!.renderExport(
        { asset, phase: "clean", target: "source.model" },
        { height: 1080, pixelRatio: 1, width: 1920 },
      );
    });

    expect(binding.create).toHaveBeenNthCalledWith(
      1,
      expect.objectContaining({ canonicalDocumentRef: "document:a" }),
      expect.anything(),
    );
    expect(binding.create).toHaveBeenNthCalledWith(
      2,
      expect.objectContaining({ canonicalDocumentRef: "document:a" }),
      expect.anything(),
    );
    expect(resolveResource).toHaveBeenCalledTimes(1);
    expect(resources).toHaveLength(2);
    expect(resources[0]!.root).not.toBe(resources[1]!.root);
    expect(geometries[0]).toBe(geometries[1]);
    expect(binding.dispose).toHaveBeenCalledTimes(1);
    const geometryDispose = vi.spyOn(geometries[0]!, "dispose");

    view.unmount();
    await waitFor(() => {
      expect(binding.dispose).toHaveBeenCalledTimes(2);
      expect(geometryDispose).toHaveBeenCalledTimes(1);
    });
  });

  it("reuses resources across canonical document replacement and disposes on reset and unmount", async () => {
    const { binding, host, unmount } = await renderHost();
    const renderDocument = async (canonicalDocumentRef: string) => {
      await host.renderPreview(
        "layer-1",
        {
          asset,
          canonicalDocumentRef,
          phase: "clean",
          target: "source.model",
          viewport: { height: 200, width: 300 },
        },
        previewContext,
      );
    };

    await act(async () => renderDocument("document:a"));
    await act(async () => renderDocument("document:b"));
    expect(binding.create).toHaveBeenCalledTimes(1);
    expect(binding.update).toHaveBeenCalledTimes(1);
    expect(binding.update).toHaveBeenCalledWith(
      expect.anything(),
      expect.objectContaining({ canonicalDocumentRef: "document:b" }),
      expect.anything(),
    );
    expect(binding.dispose).not.toHaveBeenCalled();

    act(() => host.release("layer-1"));
    expect(binding.dispose).toHaveBeenCalledTimes(1);

    await act(async () => renderDocument("document:c"));
    unmount();
    await waitFor(() => expect(binding.dispose).toHaveBeenCalledTimes(2));
  });

  it("disposes a newly created resource when preview rendering fails", async () => {
    const binding = createBinding();
    vi.mocked(binding.renderPreview).mockImplementation(() => {
      throw new Error("preview failed");
    });
    const reportPresentationFeedback = vi.fn();
    const { host } = await renderHost(binding, {
      report: reportPresentationFeedback,
    });

    await expect(
      host.renderPreview(
        "layer-1",
        { asset, phase: "clean", target: "source.model" },
        previewContext,
      ),
    ).rejects.toThrow("preview failed");
    expect(binding.dispose).toHaveBeenCalledTimes(1);
    expect(reportPresentationFeedback).toHaveBeenCalledWith(
      "source.model",
      expect.objectContaining({ code: "model-presentation-unavailable" }),
    );
    expect(host.hitTest("layer-1", { clientX: 50, clientY: 40 })).toBe(false);
  });

  it("keeps resources isolated across provider instances", async () => {
    const firstBinding = createBinding();
    const secondBinding = createBinding();
    let firstHost: ToolcraftModelRenderHost | undefined;
    let secondHost: ToolcraftModelRenderHost | undefined;
    const view = render(
      <>
        <ToolcraftModelRenderProvider
          bindings={{ "source.model": firstBinding }}
          resolveResource={createResolver()}
          standardBinding={createBinding()}
        >
          <HostProbe onHost={(value) => { firstHost = value; }} />
        </ToolcraftModelRenderProvider>
        <ToolcraftModelRenderProvider
          bindings={{ "source.model": secondBinding }}
          resolveResource={createResolver()}
          standardBinding={createBinding()}
        >
          <HostProbe onHost={(value) => { secondHost = value; }} />
        </ToolcraftModelRenderProvider>
      </>,
    );
    await waitFor(() => {
      expect(firstHost).toBeDefined();
      expect(secondHost).toBeDefined();
    });
    await act(async () => {
      await Promise.all([
        firstHost!.renderPreview(
          "shared-key",
          { asset, phase: "clean", target: "source.model" },
          previewContext,
        ),
        secondHost!.renderPreview(
          "shared-key",
          { asset, phase: "clean", target: "source.model" },
          previewContext,
        ),
      ]);
    });

    act(() => firstHost!.release("shared-key"));
    expect(firstBinding.dispose).toHaveBeenCalledTimes(1);
    expect(secondBinding.dispose).not.toHaveBeenCalled();

    view.unmount();
    await waitFor(() => expect(secondBinding.dispose).toHaveBeenCalledTimes(1));
  });

  it("disposes active shared presentation resources with the provider", async () => {
    const { host, unmount } = await renderHost();
    const lease = await host.acquirePresentation("document:a", {
      purpose: "preview",
      signal: new AbortController().signal,
    });
    const mesh = lease.root.getObjectByName("primitive-1") as Mesh;
    const geometryDispose = vi.spyOn(mesh.geometry, "dispose");
    const materialDispose = vi.spyOn(
      mesh.material as MeshStandardMaterial,
      "dispose",
    );

    unmount();
    await waitFor(() => {
      expect(geometryDispose).toHaveBeenCalledTimes(1);
      expect(materialDispose).toHaveBeenCalledTimes(1);
    });
    expect(lease.root.children).toHaveLength(0);
    lease.release();
    expect(geometryDispose).toHaveBeenCalledTimes(1);
  });
});
