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

import { ApiError } from "@/shared/api/api-error";
import { AUDIO_FILE_POLICY } from "@/shared/validation/audio-file-policy";
import { fileWithSize, readJsonPart, renderAudioForm, requireFormData } from "@/features/audio-contents/tests/audio-form-test-support";
import type { CapturedRequest } from "@/features/audio-contents/tests/audio-form-test-support";
import type { UploadAudioContentRequest } from "@/features/audio-contents/components/AudioContentForm";
import type { CropSourceImage } from "@/shared/ui/image-crop-dialog";

class FakeXMLHttpRequest {
  static instances: FakeXMLHttpRequest[] = [];

  readonly headers = new Map<string, string>();
  readonly upload = new EventTarget();
  body: XMLHttpRequestBodyInit | null = null;
  method = "";
  onerror: ((event: Event) => void) | null = null;
  onload: ((event: Event) => void) | null = null;
  responseText = "";
  status = 0;
  url = "";

  constructor() {
    FakeXMLHttpRequest.instances.push(this);
  }

  abort(): void {}

  open(method: string, url: string): void {
    this.method = method;
    this.url = url;
  }

  setRequestHeader(name: string, value: string): void {
    this.headers.set(name, value);
  }

  send(body: XMLHttpRequestBodyInit | null): void {
    this.body = body;
  }

  progress(loaded: number, total: number): void {
    this.upload.dispatchEvent(new ProgressEvent("progress", { lengthComputable: true, loaded, total }));
  }

  succeed(contentId: number): void {
    this.status = 200;
    this.responseText = JSON.stringify({ success: true, message: null, data: { contentId }, errorProperty: null });
    this.onload?.(new Event("load"));
  }
}

afterEach(() => {
  FakeXMLHttpRequest.instances = [];
  vi.unstubAllEnvs();
  vi.unstubAllGlobals();
});

async function fillValidCreateForm(): Promise<void> {
  fireEvent.change(await screen.findByLabelText("제목"), { target: { value: "재시도 오디오" } });
  fireEvent.change(screen.getByLabelText("상세 설명"), { target: { value: "재시도 설명" } });
  fireEvent.change(screen.getByLabelText("태그"), { target: { value: "재시도" } });
  fireEvent.change(screen.getByLabelText("가격"), { target: { value: "100" } });
  fireEvent.change(screen.getByLabelText("오디오 테마"), { target: { value: "7" } });
  fireEvent.change(screen.getByLabelText("오디오 파일"), { target: { files: [new File(["audio"], "voice.aac", { type: "audio/aac" })] } });
  fireEvent.change(screen.getByLabelText("커버 이미지"), { target: { files: [new File(["cover"], "cover.png", { type: "image/png" })] } });
  fireEvent.click(await screen.findByRole("button", { name: "적용" }));
  await waitFor(() => expect(screen.getByText("cover.png")).toBeInTheDocument());
}

function requireFile(part: FormDataEntryValue | null): File {
  if (part instanceof File) {
    return part;
  }

  throw new TypeError("Expected File part");
}

test("AudioContentFormPage create uses the upload adapter even when the default request is used", async () => {
  // Given
  const requests: CapturedRequest[] = [];
  vi.stubEnv("VITE_API_BASE_URL", "https://api.example.com");
  vi.stubEnv("VITE_API_MODE", "server");
  vi.stubGlobal("XMLHttpRequest", FakeXMLHttpRequest);
  window.history.pushState({}, "", "/ai-characters/101/audio-contents/new");
  renderAudioForm({ requests });

  // When
  await fillValidCreateForm();
  fireEvent.click(screen.getByRole("button", { name: "생성" }));

  // Then
  await waitFor(() => expect(FakeXMLHttpRequest.instances).toHaveLength(1));
  const request = FakeXMLHttpRequest.instances[0];
  if (request === undefined) {
    throw new Error("expected upload request");
  }
  request.progress(512, 1024);
  await waitFor(() => expect(screen.getByLabelText("업로드 진행률")).toHaveAttribute("aria-valuenow", "50"));
  request.succeed(9301);
  await waitFor(() => expect(window.location.pathname).toBe("/ai-characters/101/audio-contents/9301"));
  expect(request.method).toBe("POST");
  expect(request.url).toBe("https://api.example.com/api/v2/admin/ai-characters/101/audio-contents");
  expect(requests.some((item) => item.path === "/api/v2/admin/ai-characters/101/audio-contents" && item.method === "POST")).toBe(false);
});

test("AudioContentFormPage uses the shared audio MIME policy for file picker accept", async () => {
  // Given
  const requests: CapturedRequest[] = [];
  window.history.pushState({}, "", "/ai-characters/101/audio-contents/new");
  renderAudioForm({ requests });

  // Then
  expect(await screen.findByLabelText("오디오 파일")).toHaveAttribute("accept", AUDIO_FILE_POLICY.allowedMimeTypes.join(","));
});

test("AudioContentFormPage marks canceled upload without a form error", async () => {
  // Given
  const requests: CapturedRequest[] = [];
  const cancelingUpload: UploadAudioContentRequest = (options) => new Promise((_resolve, reject) => {
    options.signal?.addEventListener("abort", () => reject(new DOMException("Upload aborted", "AbortError")), { once: true });
  });
  window.history.pushState({}, "", "/ai-characters/101/audio-contents/new");
  renderAudioForm({ requests, uploadAudioContentRequest: cancelingUpload });

  // When
  await fillValidCreateForm();
  fireEvent.click(screen.getByRole("button", { name: "생성" }));
  fireEvent.click(await screen.findByRole("button", { name: "업로드 취소" }));

  // Then
  expect(await screen.findByText("취소됨")).toBeInTheDocument();
  expect(screen.queryByText("오디오 콘텐츠 저장에 실패했습니다.")).not.toBeInTheDocument();
  expect(screen.queryByRole("button", { name: "다시 시도" })).not.toBeInTheDocument();
});

test("AudioContentFormPage keeps a pending upload to one request and allows resubmit after cancel", async () => {
  // Given
  const requests: CapturedRequest[] = [];
  let uploadAttempts = 0;
  const pendingUpload: UploadAudioContentRequest = (options) => {
    uploadAttempts += 1;

    return new Promise((_resolve, reject) => {
      options.signal?.addEventListener("abort", () => reject(new DOMException("Upload aborted", "AbortError")), { once: true });
    });
  };
  window.history.pushState({}, "", "/ai-characters/101/audio-contents/new");
  renderAudioForm({ requests, uploadAudioContentRequest: pendingUpload });

  // When
  await fillValidCreateForm();
  const submitButton = screen.getByRole("button", { name: "생성" });
  fireEvent.click(submitButton);
  fireEvent.click(submitButton);

  // Then
  await waitFor(() => expect(uploadAttempts).toBe(1));

  // When
  fireEvent.click(await screen.findByRole("button", { name: "업로드 취소" }));
  expect(await screen.findByText("취소됨")).toBeInTheDocument();
  fireEvent.click(screen.getByRole("button", { name: "생성" }));

  // Then
  await waitFor(() => expect(uploadAttempts).toBe(2));
});

test("AudioContentFormPage maps 415 file errors to the matching file fields", async () => {
  // Given
  const requests: CapturedRequest[] = [];
  const contentFileFailure: UploadAudioContentRequest = async () => {
    throw new ApiError({ status: 415, message: "지원하지 않는 오디오 형식입니다.", errorProperty: "contentFile" });
  };
  window.history.pushState({}, "", "/ai-characters/101/audio-contents/new");
  renderAudioForm({ requests, uploadAudioContentRequest: contentFileFailure });

  // When
  await fillValidCreateForm();
  fireEvent.click(screen.getByRole("button", { name: "생성" }));

  // Then
  expect(await screen.findByText("지원하지 않는 오디오 형식입니다.")).toBeInTheDocument();
  expect(screen.getByLabelText("오디오 파일")).toHaveAttribute("aria-invalid", "true");
});

test("AudioContentFormPage retry uses the latest edited fields after an upload failure", async () => {
  // Given
  const requests: CapturedRequest[] = [];
  const uploadedBodies: XMLHttpRequestBodyInit[] = [];
  let uploadAttempts = 0;
  const failingUpload: UploadAudioContentRequest = async (options) => {
    uploadAttempts += 1;
    uploadedBodies.push(options.body);
    if (uploadAttempts === 1) {
      options.onProgress?.(30);
      throw new ApiError({ status: 415, message: "지원하지 않는 오디오 형식입니다.", errorProperty: "contentFile" });
    }
    options.onProgress?.(100);

    return options.responseSchema.parse({ contentId: 9301 });
  };
  window.history.pushState({}, "", "/ai-characters/101/audio-contents/new");
  renderAudioForm({ requests, uploadAudioContentRequest: failingUpload });

  // When
  await fillValidCreateForm();
  fireEvent.click(screen.getByRole("button", { name: "생성" }));
  expect(await screen.findByText("지원하지 않는 오디오 형식입니다.")).toBeInTheDocument();
  fireEvent.change(screen.getByLabelText("제목"), { target: { value: "수정 후 재시도" } });
  fireEvent.click(screen.getByRole("button", { name: "다시 시도" }));

  // Then
  await waitFor(() => expect(window.location.pathname).toBe("/ai-characters/101/audio-contents/9301"));
  expect(uploadAttempts).toBe(2);
  expect(await readJsonPart(requireFormData(uploadedBodies[1]).get("request"))).toMatchObject({ title: "수정 후 재시도" });
});

test("AudioContentFormPage crop cancel does not commit the uncropped cover image", async () => {
  // Given
  const requests: CapturedRequest[] = [];
  window.history.pushState({}, "", "/ai-characters/101/audio-contents/new");
  renderAudioForm({ requests });

  // When
  fireEvent.change(await screen.findByLabelText("커버 이미지"), { target: { files: [new File(["cover"], "cover.png", { type: "image/png" })] } });
  fireEvent.click(await screen.findByRole("button", { name: "취소" }));

  // Then
  expect(screen.queryByText("cover.png")).not.toBeInTheDocument();
  expect(screen.getAllByText("선택된 파일 없음")).toHaveLength(2);
  expect(screen.getAllByText("파일 선택")).toHaveLength(2);
});

test("AudioContentFormPage rejects an oversized cover before crop preparation", async () => {
  // Given
  const requests: CapturedRequest[] = [];
  const createCropSource = vi.fn<(file: File) => Promise<CropSourceImage>>((file) => Promise.resolve({ file, height: 800, previewUrl: "blob:cover", width: 800 }));
  window.history.pushState({}, "", "/ai-characters/101/audio-contents/new");
  renderAudioForm({ requests, createCropSource });

  // When
  fireEvent.change(await screen.findByLabelText("커버 이미지"), { target: { files: [fileWithSize("too-large.png", "image/png", 10_485_761)] } });

  // Then
  expect(await screen.findByText("커버 이미지는 10MB 이하만 업로드할 수 있습니다.")).toBeInTheDocument();
  expect(createCropSource).not.toHaveBeenCalled();
  expect(screen.queryByRole("dialog", { name: "이미지 crop" })).not.toBeInTheDocument();
});

test("AudioContentFormPage blocks create submit while cover crop source is preparing", async () => {
  // Given
  const requests: CapturedRequest[] = [];
  let uploadAttempts = 0;
  const pendingUpload: UploadAudioContentRequest = async (options) => {
    uploadAttempts += 1;
    return options.responseSchema.parse({ contentId: 9301 });
  };
  window.history.pushState({}, "", "/ai-characters/101/audio-contents/new");
  renderAudioForm({ requests, createCropSource: () => new Promise(() => undefined), uploadAudioContentRequest: pendingUpload });

  // When
  fireEvent.change(await screen.findByLabelText("제목"), { target: { value: "준비 중 오디오" } });
  fireEvent.change(screen.getByLabelText("상세 설명"), { target: { value: "준비 중 설명" } });
  fireEvent.change(screen.getByLabelText("태그"), { target: { value: "준비" } });
  fireEvent.change(screen.getByLabelText("가격"), { target: { value: "100" } });
  fireEvent.change(screen.getByLabelText("오디오 테마"), { target: { value: "7" } });
  fireEvent.change(screen.getByLabelText("오디오 파일"), { target: { files: [new File(["audio"], "voice.aac", { type: "audio/aac" })] } });
  fireEvent.change(screen.getByLabelText("커버 이미지"), { target: { files: [new File(["cover"], "pending.png", { type: "image/png" })] } });

  // Then
  await waitFor(() => expect(screen.getByRole("button", { name: "생성" })).toBeDisabled());
  fireEvent.click(screen.getByRole("button", { name: "생성" }));
  expect(uploadAttempts).toBe(0);
});

test("AudioContentFormPage ignores stale cover crop sources and uploads the latest applied cover", async () => {
  // Given
  const requests: CapturedRequest[] = [];
  const cropResolvers = new Map<string, (source: CropSourceImage) => void>();
  const uploadedBodies: BodyInit[] = [];
  const uploadRequest: UploadAudioContentRequest = async (options) => {
    uploadedBodies.push(options.body);
    return options.responseSchema.parse({ contentId: 9301 });
  };
  window.history.pushState({}, "", "/ai-characters/101/audio-contents/new");
  renderAudioForm({
    requests,
    createCropSource: (file) => new Promise((resolve) => cropResolvers.set(file.name, resolve)),
    renderCrop: (request) => Promise.resolve(request.file),
    uploadAudioContentRequest: uploadRequest,
  });
  fireEvent.change(await screen.findByLabelText("제목"), { target: { value: "최신 커버 오디오" } });
  fireEvent.change(screen.getByLabelText("상세 설명"), { target: { value: "최신 커버 설명" } });
  fireEvent.change(screen.getByLabelText("태그"), { target: { value: "최신" } });
  fireEvent.change(screen.getByLabelText("가격"), { target: { value: "100" } });
  fireEvent.change(screen.getByLabelText("오디오 테마"), { target: { value: "7" } });
  fireEvent.change(screen.getByLabelText("오디오 파일"), { target: { files: [new File(["audio"], "voice.aac", { type: "audio/aac" })] } });
  const staleFile = new File(["stale"], "stale.png", { type: "image/png" });
  const freshFile = new File(["fresh"], "fresh.png", { type: "image/png" });

  // When
  fireEvent.change(screen.getByLabelText("커버 이미지"), { target: { files: [staleFile] } });
  fireEvent.change(screen.getByLabelText("커버 이미지"), { target: { files: [freshFile] } });
  cropResolvers.get("fresh.png")?.({ file: freshFile, height: 800, previewUrl: "blob:fresh", width: 800 });
  fireEvent.click(await screen.findByRole("button", { name: "적용" }));
  await waitFor(() => expect(screen.getByText("fresh.png")).toBeInTheDocument());
  cropResolvers.get("stale.png")?.({ file: staleFile, height: 800, previewUrl: "blob:stale", width: 800 });

  // Then
  await waitFor(() => expect(screen.queryByRole("dialog", { name: "이미지 crop" })).not.toBeInTheDocument());
  fireEvent.click(screen.getByRole("button", { name: "생성" }));
  await waitFor(() => expect(window.location.pathname).toBe("/ai-characters/101/audio-contents/9301"));
  expect(requireFile(requireFormData(uploadedBodies[0]).get("coverImage")).name).toBe("fresh.png");
});

test("AudioContentFormPage shows a cover preparation error when preview creation rejects", async () => {
  // Given
  const requests: CapturedRequest[] = [];
  window.history.pushState({}, "", "/ai-characters/101/audio-contents/new");
  renderAudioForm({ requests, createCropSource: () => Promise.reject(new Error("preview failed")) });

  // When
  fireEvent.change(await screen.findByLabelText("커버 이미지"), { target: { files: [new File(["cover"], "broken.png", { type: "image/png" })] } });

  // Then
  expect(await screen.findByRole("alert")).toHaveTextContent("이미지 미리보기 준비에 실패했습니다.");
  expect(screen.queryByRole("dialog", { name: "이미지 crop" })).not.toBeInTheDocument();
});
