import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { render, screen } from "~/test-utils.js";
import userEvent from "@testing-library/user-event";
import React, { type ErrorInfo } from "react";
import { ErrorBoundary, DefaultErrorFallback } from "./ErrorBoundary.js";

// Suppress React's console.error output during error boundary tests
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;

beforeEach(() => {
  consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
});

afterEach(() => {
  consoleErrorSpy.mockRestore();
});

// A component that unconditionally throws
function ThrowingChild({ message = "Test error" }: { message?: string }): React.ReactNode {
  throw new Error(message);
}

// A component that throws only when `shouldThrow` is true
function ConditionalThrower({ shouldThrow }: { shouldThrow: boolean }) {
  if (shouldThrow) throw new Error("Conditional error");
  return <div>OK</div>;
}

describe("ErrorBoundary", () => {
  describe("normal rendering", () => {
    it("renders children when no error is thrown", () => {
      render(
        <ErrorBoundary>
          <div>Hello World</div>
        </ErrorBoundary>,
      );
      expect(screen.getByText("Hello World")).toBeInTheDocument();
    });

    it("renders multiple children normally", () => {
      render(
        <ErrorBoundary>
          <span>First</span>
          <span>Second</span>
        </ErrorBoundary>,
      );
      expect(screen.getByText("First")).toBeInTheDocument();
      expect(screen.getByText("Second")).toBeInTheDocument();
    });
  });

  describe("error catching", () => {
    it("catches a thrown error and renders the default fallback UI", () => {
      render(
        <ErrorBoundary>
          <ThrowingChild />
        </ErrorBoundary>,
      );
      expect(screen.getByText("Something went wrong")).toBeInTheDocument();
    });

    it("displays the error message in the default fallback", () => {
      render(
        <ErrorBoundary>
          <ThrowingChild message="Custom error message" />
        </ErrorBoundary>,
      );
      expect(screen.getByText("Custom error message")).toBeInTheDocument();
    });

    it("renders a custom fallback node instead of the default when provided", () => {
      render(
        <ErrorBoundary fallback={<div>Custom fallback</div>}>
          <ThrowingChild />
        </ErrorBoundary>,
      );
      expect(screen.getByText("Custom fallback")).toBeInTheDocument();
      expect(screen.queryByText("Something went wrong")).not.toBeInTheDocument();
    });

    it("calls onError callback when an error is caught", () => {
      const handleError = vi.fn();
      render(
        <ErrorBoundary onError={handleError}>
          <ThrowingChild message="Tracked error" />
        </ErrorBoundary>,
      );
      expect(handleError).toHaveBeenCalledOnce();
      const [error, info] = handleError.mock.calls[0] as [Error, ErrorInfo];
      expect(error.message).toBe("Tracked error");
      expect(info).toHaveProperty("componentStack");
    });

    it("does not call onError when no error is thrown", () => {
      const handleError = vi.fn();
      render(
        <ErrorBoundary onError={handleError}>
          <div>No error here</div>
        </ErrorBoundary>,
      );
      expect(handleError).not.toHaveBeenCalled();
    });
  });

  describe("default fallback UI", () => {
    it("renders the 'Something went wrong' heading", () => {
      render(
        <ErrorBoundary>
          <ThrowingChild />
        </ErrorBoundary>,
      );
      expect(screen.getByRole("heading", { name: "Something went wrong" })).toBeInTheDocument();
    });

    it("renders a 'Try again' button", () => {
      render(
        <ErrorBoundary>
          <ThrowingChild />
        </ErrorBoundary>,
      );
      expect(screen.getByRole("button", { name: "Try again" })).toBeInTheDocument();
    });

    it("renders a 'Go to dashboard' button", () => {
      render(
        <ErrorBoundary>
          <ThrowingChild />
        </ErrorBoundary>,
      );
      expect(screen.getByRole("button", { name: "Go to dashboard" })).toBeInTheDocument();
    });

    it("shows a generic message when error.message is empty", () => {
      function ThrowEmptyMessage(): React.ReactNode {
        const e = new Error("");
        throw e;
      }
      render(
        <ErrorBoundary>
          <ThrowEmptyMessage />
        </ErrorBoundary>,
      );
      expect(
        screen.getByText("An unexpected error occurred. The team has been notified."),
      ).toBeInTheDocument();
    });
  });

  describe("reset / Try again", () => {
    it("clears the error state and re-renders children when Try again is clicked", async () => {
      const user = userEvent.setup();

      // Use a mutable ref-like object to control throwing from outside JSX
      let shouldThrow = true;

      function ControlledThrower() {
        if (shouldThrow) throw new Error("Controlled error");
        return <div>OK</div>;
      }

      const { rerender } = render(
        <ErrorBoundary>
          <ControlledThrower />
        </ErrorBoundary>,
      );

      expect(screen.getByText("Something went wrong")).toBeInTheDocument();

      // Stop throwing, then click "Try again" so ErrorBoundary resets its state
      shouldThrow = false;
      await user.click(screen.getByRole("button", { name: "Try again" }));

      // Trigger a re-render so ControlledThrower runs with shouldThrow=false
      rerender(
        <ErrorBoundary>
          <ControlledThrower />
        </ErrorBoundary>,
      );

      expect(screen.getByText("OK")).toBeInTheDocument();
      expect(screen.queryByText("Something went wrong")).not.toBeInTheDocument();
    });
  });
});

describe("edge cases", () => {
  it("catches a thrown string (non-Error object)", () => {
    function ThrowString(): React.ReactNode {
      throw "string error";
    }
    const spy = vi.spyOn(console, "error").mockImplementation(() => {});
    render(
      <ErrorBoundary>
        <ThrowString />
      </ErrorBoundary>,
    );
    expect(screen.getByText(/something went wrong/i)).toBeInTheDocument();
    spy.mockRestore();
  });
});

describe("DefaultErrorFallback", () => {
  it("renders the heading", () => {
    render(<DefaultErrorFallback error={new Error("Boom")} reset={vi.fn()} />);
    expect(screen.getByRole("heading", { name: "Something went wrong" })).toBeInTheDocument();
  });

  it("renders the error message", () => {
    render(<DefaultErrorFallback error={new Error("Specific error")} reset={vi.fn()} />);
    expect(screen.getByText("Specific error")).toBeInTheDocument();
  });

  it("falls back to generic text when error message is empty", () => {
    render(<DefaultErrorFallback error={new Error("")} reset={vi.fn()} />);
    expect(
      screen.getByText("An unexpected error occurred. The team has been notified."),
    ).toBeInTheDocument();
  });

  it("calls reset when Try again is clicked", async () => {
    const user = userEvent.setup();
    const handleReset = vi.fn();
    render(<DefaultErrorFallback error={new Error("Err")} reset={handleReset} />);
    await user.click(screen.getByRole("button", { name: "Try again" }));
    expect(handleReset).toHaveBeenCalledOnce();
  });

  it("navigates to /dashboard when Go to dashboard is clicked", async () => {
    const user = userEvent.setup();
    // jsdom does not navigate; spy on the assignment
    const originalHref = window.location.href;
    Object.defineProperty(window, "location", {
      value: { href: originalHref },
      writable: true,
    });
    render(<DefaultErrorFallback error={new Error("Err")} reset={vi.fn()} />);
    await user.click(screen.getByRole("button", { name: "Go to dashboard" }));
    expect(window.location.href).toBe("/dashboard");
  });
});
