import { describe, it, expect, vi, beforeEach } from "vitest";
import { render as rtlRender, screen, waitFor } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import userEvent from "@testing-library/user-event";
import PhotoTools from "../PhotoTools";

const render = (ui: React.ReactElement) => rtlRender(<MemoryRouter>{ui}</MemoryRouter>);
import { api } from "../../api";

vi.mock("../../api", () => ({
  api: { searchByPhoto: vi.fn(), estimateBreed: vi.fn() },
  ApiError: class ApiError extends Error {
    status: number;
    constructor(status: number, message: string) {
      super(message);
      this.status = status;
    }
  },
}));

// jsdom has no canvas, so the real browser-side downscale/HEIC conversion can't run here.
// Pass files through by default; individual tests can make it reject to exercise the error path.
vi.mock("../../lib/prepareImage", () => ({
  prepareImage: vi.fn(async (f: File) => f),
  UnreadableImageError: class UnreadableImageError extends Error {},
}));
import { prepareImage } from "../../lib/prepareImage";
const mockPrepare = prepareImage as unknown as ReturnType<typeof vi.fn>;
const mockApi = api as unknown as {
  searchByPhoto: ReturnType<typeof vi.fn>;
  estimateBreed: ReturnType<typeof vi.fn>;
};

// jsdom doesn't implement object URLs; the component uses them for the thumbnail previews.
beforeEach(() => {
  vi.clearAllMocks();
  mockPrepare.mockImplementation(async (f: File) => f);
  globalThis.URL.createObjectURL = vi.fn(() => "blob:preview");
  globalThis.URL.revokeObjectURL = vi.fn();
  mockApi.searchByPhoto.mockResolvedValue({
    results: [
      {
        dog: {
          kind: "unknown", id: 9, name: "Found dog", breed: "Lab", color: "black", size: null,
          status: "at_shelter", zip: "77002", dataset_id: 2, dataset_name: "U",
          picture_count: 2, thumb_url: null, predicted_breeds: [],
        },
        score: 0.94,
        distance_miles: 3.2,
        photos: [
          { id: 1, url: "/media/a.jpg", thumb_url: "/media/a.jpg", is_primary: true },
          { id: 2, url: "/media/b.jpg", thumb_url: "/media/b.jpg", is_primary: false },
        ],
      },
    ],
    model: "hf-embed/v1",
    candidate_count: 5,
    zip: null,
    radius_miles: -1,
    pool: "found",
  });
  mockApi.estimateBreed.mockResolvedValue({
    model: "hf-breed/Dogs-Breed-Image-Classification-V1",
    breeds: [
      { label: "border collie", score: 0.62 },
      { label: "australian shepherd", score: 0.21 },
      { label: "kelpie", score: 0.08 },
    ],
  });
});

function file(name = "dog.jpg") {
  return new File([new Uint8Array([1, 2, 3])], name, { type: "image/jpeg" });
}

describe("PhotoTools", () => {
  it("opens on the matching tab and finds matches for one image", async () => {
    const user = userEvent.setup();
    render(<PhotoTools />);

    expect(screen.getByRole("tab", { name: /Image matching/i })).toHaveAttribute(
      "aria-selected",
      "true"
    );

    await user.upload(screen.getByLabelText(/Upload images of your dog/i), file());
    expect(screen.getByAltText(/Your dog, image 1/i)).toBeInTheDocument();

    await user.click(screen.getByRole("button", { name: /Find matches/i }));

    await waitFor(() =>
      expect(mockApi.searchByPhoto).toHaveBeenCalledWith([expect.any(File)], undefined, "found")
    );
    // The component holds results back for a minimum display time (MIN_PROGRESS_MS) so the staged
    // progress steps are readable — give the wait a longer timeout than the default 1000ms.
    expect(await screen.findByText("Found dog", {}, { timeout: 3000 })).toBeInTheDocument();
    expect(screen.getByText("94.0% match")).toBeInTheDocument();
    expect(mockApi.estimateBreed).not.toHaveBeenCalled();
  });

  it("accepts multiple images (up to 6) and sends them all", async () => {
    const user = userEvent.setup();
    render(<PhotoTools />);

    const input = screen.getByLabelText(/Upload images of your dog/i);
    await user.upload(input, [file("a.jpg"), file("b.jpg"), file("c.jpg")]);
    expect(screen.getByAltText(/Your dog, image 1/i)).toBeInTheDocument();
    expect(screen.getByAltText(/Your dog, image 3/i)).toBeInTheDocument();
    expect(screen.getByText(/3 of 6 images/i)).toBeInTheDocument();

    await user.click(screen.getByRole("button", { name: /Find matches/i }));
    await waitFor(() =>
      expect(mockApi.searchByPhoto).toHaveBeenCalledWith(
        [expect.any(File), expect.any(File), expect.any(File)],
        undefined,
        "found"
      )
    );
  });

  it("removes an image via its remove button", async () => {
    const user = userEvent.setup();
    render(<PhotoTools />);

    await user.upload(screen.getByLabelText(/Upload images of your dog/i), [file("a.jpg"), file("b.jpg")]);
    expect(screen.getByText(/2 of 6 images/i)).toBeInTheDocument();

    await user.click(screen.getByRole("button", { name: /Remove image 1/i }));
    expect(screen.getByText(/1 of 6 images/i)).toBeInTheDocument();
  });

  it("searches the lost pool when the 'I found a dog' toggle is chosen", async () => {
    const user = userEvent.setup();
    render(<PhotoTools />);

    await user.upload(screen.getByLabelText(/Upload images of your dog/i), file());
    await user.click(screen.getByRole("button", { name: /I found a dog/i }));
    await user.click(screen.getByRole("button", { name: /Find matches/i }));

    await waitFor(() =>
      expect(mockApi.searchByPhoto).toHaveBeenCalledWith([expect.any(File)], undefined, "lost")
    );
  });

  it("still uploads an image this browser can't preview (e.g. HEIC outside Safari)", async () => {
    const user = userEvent.setup();
    mockPrepare.mockRejectedValue(new Error("undecodable"));
    render(<PhotoTools />);

    await user.upload(screen.getByLabelText(/Upload images of your dog/i), file("holiday.heic"));

    // Falls back to a labelled placeholder instead of a broken thumbnail...
    expect(await screen.findByText(/No preview/i)).toBeInTheDocument();
    expect(screen.getByText(/holiday\.heic/i)).toBeInTheDocument();
    expect(screen.getByText(/will still be searched/i)).toBeInTheDocument();

    // ...and the original file is still sent to the server, which can decode HEIC itself.
    await user.click(screen.getByRole("button", { name: /Find matches/i }));
    await waitFor(() =>
      expect(mockApi.searchByPhoto).toHaveBeenCalledWith([expect.any(File)], undefined, "found")
    );
  });

  it("has no ZIP code field", () => {
    render(<PhotoTools />);
    expect(screen.queryByLabelText(/ZIP code/i)).not.toBeInTheDocument();
  });

  it("estimates breed across all uploaded images, always asking for the top 10", async () => {
    const user = userEvent.setup();
    render(<PhotoTools />);

    await user.upload(screen.getByLabelText(/Upload images of your dog/i), [file("a.jpg"), file("b.jpg")]);
    await user.click(screen.getByRole("tab", { name: /Breed estimation/i }));
    // No count picker any more — the tool always requests the model's full top 10.
    expect(screen.queryByLabelText(/Breeds to estimate/i)).not.toBeInTheDocument();
    await user.click(screen.getByRole("button", { name: /Estimate breed/i }));

    await waitFor(() =>
      expect(mockApi.estimateBreed).toHaveBeenCalledWith([expect.any(File), expect.any(File)], 10)
    );
    expect(await screen.findByText("border collie", {}, { timeout: 3000 })).toBeInTheDocument();
    expect(screen.getByText("62%")).toBeInTheDocument();
    expect(mockApi.searchByPhoto).not.toHaveBeenCalled();
  });

  it("compares your images side by side with a match when its card is clicked", async () => {
    const user = userEvent.setup();
    render(<PhotoTools />);

    await user.upload(screen.getByLabelText(/Upload images of your dog/i), [file("a.jpg"), file("b.jpg")]);
    await user.click(screen.getByRole("button", { name: /Find matches/i }));
    await screen.findByText("Found dog", {}, { timeout: 3000 });

    await user.click(screen.getByRole("button", { name: /Found dog/i }));
    expect(await screen.findByRole("dialog")).toBeInTheDocument();

    // Left column = the visitor's uploads, right column = the candidate's photos.
    expect(screen.getByText(/Your images/i)).toBeInTheDocument();
    expect(screen.getByText(/Found dog \(match\)/i)).toBeInTheDocument();
    expect(screen.getAllByAltText(/Your images/i)).toHaveLength(2);
    expect(screen.getAllByAltText(/Found dog \(match\)/i)).toHaveLength(2);
  });
});
