import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { render, screen, act } from "~/test-utils.js";
import { AnimatedNumber } from "./AnimatedNumber.js";

// ---------------------------------------------------------------------------
// rAF / performance mocking
// ---------------------------------------------------------------------------

// jsdom does not implement requestAnimationFrame in a way that advances with
// fake timers, so we replace it with a minimal synchronous stub that lets us
// drive frames manually when needed.

let rafCallbacks: Map<number, FrameRequestCallback> = new Map();
let rafId = 0;
// Tracks the simulated clock so we can advance it past the animation duration.
let fakeNow = 0;

function flushAllFrames() {
  // Advance the fake clock well past the default duration (800 ms) so that
  // easeOutExpo reaches progress=1 and setDisplay(to) is called.
  fakeNow += 2000;

  let safety = 0;
  while (rafCallbacks.size > 0 && safety++ < 1000) {
    const cbs = new Map(rafCallbacks);
    rafCallbacks.clear();
    for (const cb of cbs.values()) {
      cb(fakeNow);
    }
  }
}

beforeEach(() => {
  rafCallbacks = new Map();
  rafId = 0;
  fakeNow = 0;

  // Stub performance.now so the animation component uses our controlled clock.
  vi.spyOn(performance, "now").mockImplementation(() => fakeNow);

  vi.spyOn(window, "requestAnimationFrame").mockImplementation((cb) => {
    const id = ++rafId;
    rafCallbacks.set(id, cb);
    return id;
  });

  vi.spyOn(window, "cancelAnimationFrame").mockImplementation((id) => {
    rafCallbacks.delete(id);
  });
});

afterEach(() => {
  vi.restoreAllMocks();
});

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

describe("AnimatedNumber", () => {
  // -------------------------------------------------------------------------
  // Initial render
  // -------------------------------------------------------------------------

  describe("initial render", () => {
    it("renders the initial value without animation on first mount", () => {
      act(() => {
        render(<AnimatedNumber value={42} />);
        flushAllFrames();
      });
      // The component formats with de-DE locale; 42 → "42"
      expect(screen.getByText("42")).toBeInTheDocument();
    });

    it("wraps the output in a <span>", () => {
      act(() => {
        render(<AnimatedNumber value={10} />);
        flushAllFrames();
      });
      const span = screen.getByText("10");
      expect(span.tagName.toLowerCase()).toBe("span");
    });

    it("applies the className prop to the span", () => {
      act(() => {
        render(<AnimatedNumber value={5} className="text-red-500" />);
        flushAllFrames();
      });
      const span = screen.getByText("5");
      expect(span).toHaveClass("text-red-500");
    });
  });

  // -------------------------------------------------------------------------
  // Prefix and suffix
  // -------------------------------------------------------------------------

  describe("prefix and suffix", () => {
    it("prepends the prefix string to the displayed value", () => {
      act(() => {
        render(<AnimatedNumber value={100} prefix="€ " />);
        flushAllFrames();
      });
      expect(screen.getByText(/€/)).toBeInTheDocument();
    });

    it("appends the suffix string to the displayed value", () => {
      act(() => {
        render(<AnimatedNumber value={75} suffix="%" />);
        flushAllFrames();
      });
      const span = screen.getByText(/75/);
      expect(span.textContent).toContain("%");
    });

    it("renders both prefix and suffix together", () => {
      act(() => {
        render(<AnimatedNumber value={50} prefix="~" suffix=" hrs" />);
        flushAllFrames();
      });
      const span = screen.getByText(/50/);
      expect(span.textContent).toContain("~");
      expect(span.textContent).toContain(" hrs");
    });
  });

  // -------------------------------------------------------------------------
  // Decimal formatting
  // -------------------------------------------------------------------------

  describe("decimal formatting", () => {
    it("formats with 0 decimals by default (no decimal separator)", () => {
      act(() => {
        render(<AnimatedNumber value={1234} />);
        flushAllFrames();
      });
      // de-DE uses . as thousands separator and , as decimal; 1234 → "1.234"
      const span = screen.getByText(/1/);
      expect(span.textContent).not.toMatch(/,/);
    });

    it("formats with 2 decimals when decimals={2}", () => {
      act(() => {
        render(<AnimatedNumber value={3} decimals={2} />);
        flushAllFrames();
      });
      // de-DE: 3.00 → "3,00"
      expect(screen.getByText(/3,00/)).toBeInTheDocument();
    });
  });

  // -------------------------------------------------------------------------
  // Animation on value change
  // -------------------------------------------------------------------------

  describe("animation on value change", () => {
    it("cancels any pending rAF when the component unmounts mid-animation", () => {
      const { unmount } = render(<AnimatedNumber value={0} />);
      // Trigger an animation by re-rendering with a new value but do NOT flush frames.
      act(() => {
        // The useEffect fires; a rAF is queued but not consumed.
        // We just verify that unmounting does not throw.
      });
      expect(() => unmount()).not.toThrow();
    });

    it("settles to the final value after all rAF frames are flushed", () => {
      const { rerender } = render(<AnimatedNumber value={0} />);
      // Flush any frames queued by the initial mount effect.
      act(() => {
        flushAllFrames();
      });

      // Rerender in its own act() so the useEffect fires and queues a rAF
      // before we call flushAllFrames in the next act().
      act(() => {
        rerender(<AnimatedNumber value={999} />);
      });

      // Now flush: performance.now() returns fakeNow; advancing it ensures
      // elapsed > duration so progress clamps to 1 and setDisplay(to) is called.
      act(() => {
        flushAllFrames();
      });

      // After full animation the display should equal the target value.
      expect(screen.getByText("999")).toBeInTheDocument();
    });

    it("skips animation and immediately shows the value when from === to", () => {
      // Render with value=7, then rerender with the same value=7.
      const { rerender } = render(<AnimatedNumber value={7} />);
      act(() => {
        flushAllFrames();
      });

      act(() => {
        rerender(<AnimatedNumber value={7} />);
        flushAllFrames();
      });

      expect(screen.getByText("7")).toBeInTheDocument();
    });
  });

  // -------------------------------------------------------------------------
  // Edge cases
  // -------------------------------------------------------------------------

  describe("edge cases", () => {
    it("handles value=0 without error", () => {
      act(() => {
        render(<AnimatedNumber value={0} />);
        flushAllFrames();
      });
      expect(screen.getByText("0")).toBeInTheDocument();
    });

    it("handles negative values", () => {
      act(() => {
        render(<AnimatedNumber value={-42} />);
        flushAllFrames();
      });
      expect(screen.getByText(/-42/)).toBeInTheDocument();
    });

    it("handles large numbers via de-DE thousand separators", () => {
      act(() => {
        render(<AnimatedNumber value={1000000} />);
        flushAllFrames();
      });
      // de-DE: 1_000_000 → "1.000.000"
      expect(screen.getByText("1.000.000")).toBeInTheDocument();
    });
  });
});
