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

import { calculateCropSourceRect } from "@/shared/lib/crop-image";
import type { CropRenderRequest } from "@/shared/lib/crop-image";
import { ImageCropDialog } from "@/shared/ui/image-crop-dialog";
import type { ImageCropDialogProps } from "@/shared/ui/image-crop-dialog";

type FakeCropperCoordinates = {
  readonly height: number;
  readonly left: number;
  readonly top: number;
  readonly width: number;
};

type FakeCropperProps = {
  readonly checkOrientation?: boolean;
  readonly src?: string;
  readonly stencilProps?: {
    readonly aspectRatio?: number;
  };
};

type FakeCropperHandle = {
  readonly getCoordinates: () => FakeCropperCoordinates | null;
  readonly moveImage: (left: number, top?: number) => void;
  readonly reset: () => void;
  readonly zoomImage: (scale: number) => void;
};

const cropperFake = vi.hoisted(() => {
  let checkOrientation: boolean | undefined;

  return {
    get checkOrientation() {
      return checkOrientation;
    },
    getCoordinates: vi.fn<() => FakeCropperCoordinates | null>(() => ({ height: 600, left: 300, top: 0, width: 600 })),
    moveImage: vi.fn(),
    reset: vi.fn(),
    set checkOrientation(value: boolean | undefined) {
      checkOrientation = value;
    },
    src: "",
    stencilAspectRatio: 0,
    zoomImage: vi.fn(),
  };
});

vi.mock("react-advanced-cropper", async () => {
  const { forwardRef, useImperativeHandle } = await vi.importActual<typeof import("react")>("react");

  return {
    Cropper: forwardRef<FakeCropperHandle, FakeCropperProps>(function FakeCropper(props, ref) {
      cropperFake.checkOrientation = props.checkOrientation;
      cropperFake.src = props.src ?? "";
      cropperFake.stencilAspectRatio = props.stencilProps?.aspectRatio ?? 0;
      useImperativeHandle(ref, () => ({
        getCoordinates: cropperFake.getCoordinates,
        moveImage: cropperFake.moveImage,
        reset: cropperFake.reset,
        zoomImage: cropperFake.zoomImage,
      }));

      return <div data-testid="advanced-cropper" />;
    }),
    ImageRestriction: {
      fillArea: "fillArea",
      fitArea: "fitArea",
      none: "none",
      stencil: "stencil",
    },
  };
});

const image = {
  file: new File(["image"], "profile.png", { type: "image/png" }),
  height: 600,
  previewUrl: "blob:profile",
  width: 1200,
};

const squarePolicy = { aspect: 1, maxWidth: 800, noUpscale: true } as const;

function renderDialog(props: ImageCropDialogProps): void {
  render(<ImageCropDialog {...props} />);
}

beforeEach(() => {
  vi.clearAllMocks();
  cropperFake.checkOrientation = undefined;
  cropperFake.getCoordinates.mockReturnValue({ height: 600, left: 300, top: 0, width: 600 });
  cropperFake.src = "";
  cropperFake.stencilAspectRatio = 0;
});

test("Given an open dialog, when it renders, then it shows the Cropper viewport without legacy direction or range controls", () => {
  renderDialog({ image, onApply: vi.fn(), onCancel: vi.fn(), open: true, policy: squarePolicy });

  expect(screen.getByRole("application", { name: "이미지 crop viewport" })).toHaveAttribute("tabindex", "0");
  expect(cropperFake.src).toBe(image.previewUrl);
  expect(screen.queryByRole("button", { name: "위로 이동" })).not.toBeInTheDocument();
  expect(screen.queryByRole("button", { name: "아래로 이동" })).not.toBeInTheDocument();
  expect(screen.queryByRole("button", { name: "왼쪽으로 이동" })).not.toBeInTheDocument();
  expect(screen.queryByRole("button", { name: "오른쪽으로 이동" })).not.toBeInTheDocument();
  expect(screen.queryByRole("slider", { name: "확대 비율" })).not.toBeInTheDocument();
});

test("Given a local preview URL, when the Cropper renders, then it disables the library orientation request", () => {
  renderDialog({ image, onApply: vi.fn(), onCancel: vi.fn(), open: true, policy: squarePolicy });

  expect(cropperFake.checkOrientation).toBe(false);
});

test("Given Cropper coordinates, when apply is selected, then it maps the existing render request to the same source rectangle and returns its File", async () => {
  const croppedFile = new File(["crop"], "profile-crop.png", { type: "image/png" });
  const onApply = vi.fn();
  const renderCrop = vi.fn((request: CropRenderRequest) => {
    expect(calculateCropSourceRect(request)).toEqual({ height: 600, sourceX: 300, sourceY: 0, width: 600 });
    return Promise.resolve(croppedFile);
  });
  renderDialog({ image, onApply, onCancel: vi.fn(), open: true, policy: squarePolicy, renderCrop });

  fireEvent.click(screen.getByRole("button", { name: "적용" }));

  await waitFor(() => expect(onApply).toHaveBeenCalledWith(croppedFile));
  expect(renderCrop).toHaveBeenCalledTimes(1);
});

test.each([
  {
    coordinates: { height: 300, left: 400, top: 100, width: 300 },
    expected: { height: 300, sourceX: 400, sourceY: 100, width: 300 },
    policy: squarePolicy,
  },
  {
    coordinates: { height: 1500, left: 1000, top: 500, width: 1060.5 },
    expected: { height: 1500, sourceX: 1000, sourceY: 500, width: 1061 },
    policy: { aspect: 210 / 297, maxWidth: 1000, noUpscale: true } as const,
  },
])("Given an off-center fixed-ratio crop, when apply is selected, then it preserves the Cropper source coordinates", async ({ coordinates, expected, policy }) => {
  const sourceImage = policy.aspect === 1 ? image : { ...image, height: 3000, width: 4000 };
  const renderCrop = vi.fn<(request: CropRenderRequest) => Promise<File>>(() => Promise.resolve(new File(["crop"], "profile-crop.png", { type: "image/png" })));
  cropperFake.getCoordinates.mockReturnValue(coordinates);
  renderDialog({ image: sourceImage, onApply: vi.fn(), onCancel: vi.fn(), open: true, policy, renderCrop });

  fireEvent.click(screen.getByRole("button", { name: "적용" }));

  await waitFor(() => expect(renderCrop).toHaveBeenCalledTimes(1));
  expect(calculateCropSourceRect(renderCrop.mock.calls[0]![0])).toEqual(expected);
});

test("Given Cropper coordinates are not ready, when apply is selected, then it preserves the centered crop contract", async () => {
  const croppedFile = new File(["crop"], "profile-crop.png", { type: "image/png" });
  const onApply = vi.fn();
  const renderCrop = vi.fn((request: CropRenderRequest) => {
    expect(calculateCropSourceRect(request)).toEqual({ height: 600, sourceX: 300, sourceY: 0, width: 600 });
    return Promise.resolve(croppedFile);
  });
  cropperFake.getCoordinates.mockReturnValue(null);
  renderDialog({ image, onApply, onCancel: vi.fn(), open: true, policy: squarePolicy, renderCrop });

  fireEvent.click(screen.getByRole("button", { name: "적용" }));

  await waitFor(() => expect(renderCrop).toHaveBeenCalledTimes(1));
  await waitFor(() => expect(onApply).toHaveBeenCalledWith(croppedFile));
});

test("Given a free aspect policy, when the dialog renders, then it gives the source ratio to the stencil and reports an 800 by 400 result", () => {
  cropperFake.getCoordinates.mockReturnValue({ height: 600, left: 0, top: 0, width: 1200 });
  renderDialog({ image, onApply: vi.fn(), onCancel: vi.fn(), open: true, policy: { aspect: "free", maxWidth: 800, noUpscale: true } });

  expect(cropperFake.stencilAspectRatio).toBe(2);
  expect(screen.getByText("예상 결과 800 × 400px")).toBeInTheDocument();
});

test("Given the focusable Cropper viewport, when keyboard controls and reset are used, then it delegates movement, zoom, and reset to CropperRef", () => {
  renderDialog({ image, onApply: vi.fn(), onCancel: vi.fn(), open: true, policy: squarePolicy });
  const viewport = screen.getByTestId("advanced-cropper");

  viewport.focus();
  fireEvent.keyDown(viewport, { key: "ArrowUp" });
  fireEvent.keyDown(viewport, { key: "ArrowDown" });
  fireEvent.keyDown(viewport, { key: "ArrowLeft" });
  fireEvent.keyDown(viewport, { key: "ArrowRight" });
  fireEvent.keyDown(viewport, { key: "+" });
  fireEvent.keyDown(viewport, { key: "=" });
  fireEvent.keyDown(viewport, { key: "-" });
  fireEvent.click(screen.getByRole("button", { name: "초기화" }));

  expect(cropperFake.moveImage).toHaveBeenNthCalledWith(1, 0, -10);
  expect(cropperFake.moveImage).toHaveBeenNthCalledWith(2, 0, 10);
  expect(cropperFake.moveImage).toHaveBeenNthCalledWith(3, -10, 0);
  expect(cropperFake.moveImage).toHaveBeenNthCalledWith(4, 10, 0);
  expect(cropperFake.zoomImage).toHaveBeenNthCalledWith(1, 1.1);
  expect(cropperFake.zoomImage).toHaveBeenNthCalledWith(2, 1.1);
  expect(cropperFake.zoomImage).toHaveBeenNthCalledWith(3, 0.9);
  expect(cropperFake.reset).toHaveBeenCalledTimes(1);
});

test("Given an apply request in flight, when apply is repeated and rendering fails, then it stays single-flight, shows the error, and allows retry", async () => {
  const onApply = vi.fn();
  let rejectCrop: (error: Error) => void = () => undefined;
  const renderCrop = vi.fn(() => new Promise<File>((_resolve, reject) => {
    rejectCrop = reject;
  }));
  renderDialog({ image, onApply, onCancel: vi.fn(), open: true, policy: squarePolicy, renderCrop });

  fireEvent.click(screen.getByRole("button", { name: "적용" }));
  fireEvent.click(screen.getByRole("button", { name: "적용" }));

  await screen.findByRole("status");
  expect(renderCrop).toHaveBeenCalledTimes(1);
  rejectCrop(new Error("render failed"));
  expect(await screen.findByRole("alert")).toHaveTextContent("이미지 crop을 적용하지 못했습니다.");
  fireEvent.click(screen.getByRole("button", { name: "적용" }));
  expect(renderCrop).toHaveBeenCalledTimes(2);
  expect(onApply).not.toHaveBeenCalled();
});

test("Given an open dialog inside a parent, when Escape is pressed, then it cancels once without bubbling", () => {
  const onCancel = vi.fn();
  const onParentEscape = vi.fn();
  render(
    <div onKeyDown={(event) => {
      if (event.key === "Escape") {
        onParentEscape();
      }
    }}>
      <ImageCropDialog image={image} onApply={vi.fn()} onCancel={onCancel} open policy={squarePolicy} />
    </div>,
  );

  fireEvent.keyDown(screen.getByRole("dialog", { name: "이미지 crop" }), { key: "Escape" });

  expect(onCancel).toHaveBeenCalledTimes(1);
  expect(onParentEscape).not.toHaveBeenCalled();
});

test("Given crop rendering is in progress, when Escape is pressed, then it keeps the pending result active", async () => {
  const croppedFile = new File(["crop"], "profile-crop.png", { type: "image/png" });
  const onApply = vi.fn();
  const onCancel = vi.fn();
  let resolveCrop: (file: File) => void = () => undefined;
  const renderCrop = vi.fn(() => new Promise<File>((resolve) => {
    resolveCrop = resolve;
  }));
  renderDialog({ image, onApply, onCancel, open: true, policy: squarePolicy, renderCrop });

  fireEvent.click(screen.getByRole("button", { name: "적용" }));
  await screen.findByRole("status");
  fireEvent.keyDown(screen.getByRole("dialog"), { key: "Escape" });

  expect(onCancel).not.toHaveBeenCalled();
  resolveCrop(croppedFile);
  await waitFor(() => expect(onApply).toHaveBeenCalledWith(croppedFile));
});

test("Given a closed dialog, when it renders, then it returns no dialog", () => {
  renderDialog({ image, onApply: vi.fn(), onCancel: vi.fn(), open: false, policy: squarePolicy });

  expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
