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

import type { ToolcraftSourceAssetCoordinator } from "../../source-assets/source-asset-coordinator";
import { createToolcraftSourceAssetCoordinator } from "../../source-assets/source-asset-coordinator";
import {
  createLease,
  createRepository,
  createStateHarness,
  deferred,
  type Deferred,
} from "../../source-assets/source-asset-coordinator-test-support";
import type {
  ToolcraftSourceAssetFeedback,
  ToolcraftSourceAssetImportOutcome,
} from "../../source-assets/source-asset-types";
import { ToolcraftRoot } from "../app-shell/toolcraft-root";
import { ToolcraftSourceAssetCoordinatorContext } from "../app-shell/toolcraft-source-asset-context";
import { CanvasShell } from "./canvas-shell";
import { createSchema } from "./canvas-shell-test-utils";

class ControlledFile extends File {
  static instances: ControlledFile[] = [];
  private rejectRead: ((reason: Error) => void) | undefined;

  constructor(name: string, type: string) {
    super(["pending"], name, { type });
    ControlledFile.instances.push(this);
  }

  override arrayBuffer(): Promise<ArrayBuffer> {
    return new Promise((_, reject) => {
      this.rejectRead = reject;
    });
  }

  fail(): void {
    if (!this.rejectRead) {
      throw new Error(`Expected "${this.name}" to have a pending read.`);
    }
    this.rejectRead(new Error(`Could not decode image file "${this.name}".`));
  }
}

function requireControlledFile(): ControlledFile {
  const file = ControlledFile.instances[0];
  if (!file) {
    throw new Error("Expected a controlled file instance.");
  }
  return file;
}

const ownedCoordinators: ToolcraftSourceAssetCoordinator[] = [];

afterEach(async () => {
  cleanup();
  await Promise.allSettled(ownedCoordinators.splice(0).map((coordinator) => coordinator.dispose()));
  ControlledFile.instances = [];
  vi.unstubAllGlobals();
});

function renderWithCoordinator(coordinator: ToolcraftSourceAssetCoordinator) {
  return render(
    <ToolcraftRoot schema={createSchema()}>
      <ToolcraftSourceAssetCoordinatorContext.Provider value={coordinator}>
        <div style={{ height: 640, position: "relative", width: 640 }}>
          <CanvasShell />
        </div>
      </ToolcraftSourceAssetCoordinatorContext.Provider>
    </ToolcraftRoot>,
  );
}

function dropFile(file: File): void {
  fireEvent.drop(screen.getByRole("application", { name: "Canvas viewport" }), {
    clientX: 0,
    clientY: 0,
    dataTransfer: { files: [file] },
  });
}

function createRealCoordinator(
  repository = createRepository([createLease()]),
): ToolcraftSourceAssetCoordinator {
  const harness = createStateHarness(false);
  const coordinator = createToolcraftSourceAssetCoordinator({
    dispatch: harness.dispatch,
    getState: harness.getState,
    repository,
  });
  ownedCoordinators.push(coordinator);
  return coordinator;
}

function feedback(message: string): ToolcraftSourceAssetFeedback {
  return {
    category: "resource-unavailable",
    code: "test-failure",
    message,
  };
}

function createOutcomeCoordinator(
  outcomes: readonly Deferred<ToolcraftSourceAssetImportOutcome>[],
): ToolcraftSourceAssetCoordinator {
  let index = 0;
  const idleOperation = Object.freeze({
    phase: "idle" as const,
    target: "toolcraft.canvas.image",
  });

  return {
    cancelTarget: vi.fn(),
    clearPresentationFeedback: vi.fn(),
    dispose: vi.fn(async () => undefined),
    getOperation: () => idleOperation,
    getPresentation: () => null,
    hydrateBinaryMedia: vi.fn(async () => undefined),
    hydrateModels: vi.fn(async () => undefined),
    importBatch: vi.fn(() => {
      const outcome = outcomes[index++];
      if (!outcome) throw new Error("Missing test outcome");
      return outcome.promise;
    }),
    repairModel: vi.fn(async () => ({ kind: "cancelled" as const })),
    reportPresentationFeedback: vi.fn(),
    subscribe: () => () => undefined,
  };
}

describe("CanvasShell source asset feedback", () => {
  it("announces direct-canvas decoding status and read failure", async () => {
    renderWithCoordinator(createRealCoordinator());

    dropFile(new ControlledFile("broken.png", "image/png"));

    await waitFor(() => {
      expect(screen.getByRole("status").textContent).toContain("Decoding files");
      expect(ControlledFile.instances).toHaveLength(1);
    });
    requireControlledFile().fail();

    await waitFor(() => {
      expect(screen.getByRole("alert").textContent).toContain(
        'Could not decode image file "broken.png".',
      );
    });
    expect(screen.getByRole("alert").getAttribute("data-canvas-upload-feedback-code")).toBe(
      "import-failed",
    );
    expect(screen.queryByRole("status")).toBeNull();
  });

  it("announces a direct-canvas repository failure", async () => {
    const repository = createRepository([], {
      beginLease: vi.fn(async () => {
        throw new Error("Source repository is unavailable.");
      }),
    });
    renderWithCoordinator(createRealCoordinator(repository));

    dropFile(new File(["image"], "source.png", { type: "image/png" }));

    await waitFor(() => {
      expect(screen.getByRole("alert").textContent).toContain("Source repository is unavailable.");
    });
  });

  it("ignores an older awaited import outcome after a newer drop settles", async () => {
    const first = deferred<ToolcraftSourceAssetImportOutcome>();
    const second = deferred<ToolcraftSourceAssetImportOutcome>();
    renderWithCoordinator(createOutcomeCoordinator([first, second]));

    dropFile(new File(["first"], "first.png", { type: "image/png" }));
    dropFile(new File(["second"], "second.png", { type: "image/png" }));
    second.resolve({
      feedback: feedback("Newest drop failed."),
      kind: "rejected",
    });

    await waitFor(() => {
      expect(screen.getByRole("alert").textContent).toBe("Newest drop failed.");
    });
    expect(screen.getByRole("alert").getAttribute("data-canvas-upload-feedback-code")).toBe(
      "test-failure",
    );
    first.resolve({
      feedback: feedback("Stale drop failed."),
      kind: "rejected",
    });
    await Promise.resolve();
    await Promise.resolve();

    expect(screen.getByRole("alert").textContent).toBe("Newest drop failed.");
  });

  it("hides an older direct operation after a newer non-direct drop", async () => {
    renderWithCoordinator(createRealCoordinator());

    dropFile(new ControlledFile("pending.png", "image/png"));
    await waitFor(() => expect(screen.getByRole("status")).toBeTruthy());

    dropFile(new File(["notes"], "notes.txt", { type: "text/plain" }));
    await waitFor(() => expect(screen.queryByRole("status")).toBeNull());
    requireControlledFile().fail();
    await Promise.resolve();
    await Promise.resolve();

    expect(screen.queryByRole("alert")).toBeNull();
  });
});
