import { act, render, screen } from "@testing-library/react";
import * as React from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { PipelineState } from "../api/client";
import { LozengeFlyProvider, useLozengeFly } from "./LozengeFlyContext";
import { StagePanel } from "./StagePanel";

// ─── Helpers ──────────────────────────────────────────────────────────────────

function makePipeline(overrides: Partial<PipelineState> = {}): PipelineState {
	return {
		backlog: [],
		current: [],
		qa: [],
		merge: [],
		done: [],
		deterministic_merges_in_flight: [],
		...overrides,
	};
}

/** A minimal roster element fixture that registers itself with the context. */
function RosterFixture({ agentName }: { agentName: string }) {
	const { registerRosterEl } = useLozengeFly();
	const ref = React.useRef<HTMLSpanElement>(null);
	React.useEffect(() => {
		const el = ref.current;
		if (el) registerRosterEl(agentName, el);
		return () => registerRosterEl(agentName, null);
	}, [agentName, registerRosterEl]);
	return (
		<span
			ref={ref}
			data-testid={`roster-${agentName}`}
			style={{ position: "fixed", top: 10, left: 20, width: 80, height: 20 }}
		/>
	);
}

/** Reads hiddenRosterAgents from context and exposes it via a data attribute. */
function HiddenAgentsProbe() {
	const { hiddenRosterAgents } = useLozengeFly();
	return (
		<div
			data-testid="hidden-agents-probe"
			data-hidden={[...hiddenRosterAgents].join(",")}
		/>
	);
}

function Wrapper({
	pipeline,
	children,
}: {
	pipeline: PipelineState;
	children: React.ReactNode;
}) {
	return (
		<LozengeFlyProvider pipeline={pipeline}>{children}</LozengeFlyProvider>
	);
}

// ─── hiddenRosterAgents: no-duplicate guarantee ───────────────────────────────

describe("hiddenRosterAgents: assigned agents are absent from roster", () => {
	it("is empty when no agents are in the pipeline", () => {
		render(
			<LozengeFlyProvider pipeline={makePipeline()}>
				<HiddenAgentsProbe />
			</LozengeFlyProvider>,
		);
		const probe = screen.getByTestId("hidden-agents-probe");
		expect(probe.dataset.hidden).toBe("");
	});

	it("includes agent name when agent is assigned to a current story", () => {
		const pipeline = makePipeline({
			current: [
				{
					story_id: "85_assign_test",
					name: "Assign Test",
					error: null,
					merge_failure: null,
					agent: { agent_name: "coder-1", model: null, status: "running" },
					review_hold: null,
					qa: null,
					depends_on: null,
				},
			],
		});
		render(
			<LozengeFlyProvider pipeline={pipeline}>
				<HiddenAgentsProbe />
			</LozengeFlyProvider>,
		);
		const probe = screen.getByTestId("hidden-agents-probe");
		expect(probe.dataset.hidden).toContain("coder-1");
	});

	it("excludes agent name when it has no assignment in the pipeline", () => {
		const pipeline = makePipeline({
			current: [
				{
					story_id: "85_no_agent",
					name: "No Agent",
					error: null,
					merge_failure: null,
					agent: null,
					review_hold: null,
					qa: null,
					depends_on: null,
				},
			],
		});
		render(
			<LozengeFlyProvider pipeline={pipeline}>
				<HiddenAgentsProbe />
			</LozengeFlyProvider>,
		);
		const probe = screen.getByTestId("hidden-agents-probe");
		expect(probe.dataset.hidden).toBe("");
	});

	it("updates to include agent when pipeline transitions from no-agent to assigned", async () => {
		const noPipeline = makePipeline();
		const withAgent = makePipeline({
			current: [
				{
					story_id: "85_transition_test",
					name: "Transition",
					error: null,
					merge_failure: null,
					agent: { agent_name: "coder-1", model: null, status: "running" },
					review_hold: null,
					qa: null,
					depends_on: null,
				},
			],
		});

		const { rerender } = render(
			<LozengeFlyProvider pipeline={noPipeline}>
				<HiddenAgentsProbe />
			</LozengeFlyProvider>,
		);

		let probe = screen.getByTestId("hidden-agents-probe");
		expect(probe.dataset.hidden).toBe("");

		await act(async () => {
			rerender(
				<LozengeFlyProvider pipeline={withAgent}>
					<HiddenAgentsProbe />
				</LozengeFlyProvider>,
			);
		});

		probe = screen.getByTestId("hidden-agents-probe");
		expect(probe.dataset.hidden).toContain("coder-1");
	});
});

// ─── Bug 137: Race condition on rapid pipeline updates ────────────────────

describe("Bug 137: no animation actions lost during rapid pipeline updates", () => {
	beforeEach(() => {
		vi.useFakeTimers();
		Element.prototype.getBoundingClientRect = vi.fn().mockReturnValue({
			left: 100,
			top: 50,
			right: 180,
			bottom: 70,
			width: 80,
			height: 20,
			x: 100,
			y: 50,
			toJSON: () => ({}),
		});
		vi.spyOn(window, "requestAnimationFrame").mockImplementation((cb) => {
			cb(0);
			return 0;
		});
	});

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

	it("rapid agent swap: first timeout does not prematurely reveal slot lozenge", async () => {
		const empty = makePipeline();
		const withCoder1 = makePipeline({
			current: [
				{
					story_id: "137_rapid_swap",
					name: "Rapid Swap",
					error: null,
					merge_failure: null,
					agent: { agent_name: "coder-1", model: "sonnet", status: "running" },
					review_hold: null,
					qa: null,
					depends_on: null,
				},
			],
		});
		const withCoder2 = makePipeline({
			current: [
				{
					story_id: "137_rapid_swap",
					name: "Rapid Swap",
					error: null,
					merge_failure: null,
					agent: { agent_name: "coder-2", model: "haiku", status: "running" },
					review_hold: null,
					qa: null,
					depends_on: null,
				},
			],
		});

		const { rerender } = render(
			<Wrapper pipeline={empty}>
				<RosterFixture agentName="coder-1" />
				<RosterFixture agentName="coder-2" />
				<StagePanel title="Current" items={[]} />
			</Wrapper>,
		);

		// First update: assign coder-1 → fly-in animation #1 starts
		await act(async () => {
			rerender(
				<Wrapper pipeline={withCoder1}>
					<RosterFixture agentName="coder-1" />
					<RosterFixture agentName="coder-2" />
					<StagePanel title="Current" items={withCoder1.current} />
				</Wrapper>,
			);
		});

		// Slot should be hidden (fly-in in progress)
		const lozenge = screen.getByTestId("slot-lozenge-137_rapid_swap");
		expect(lozenge.style.opacity).toBe("0");

		// Rapid swap at 200ms: coder-1 → coder-2 (before first animation's 500ms timeout)
		await act(async () => {
			vi.advanceTimersByTime(200);
		});

		await act(async () => {
			rerender(
				<Wrapper pipeline={withCoder2}>
					<RosterFixture agentName="coder-1" />
					<RosterFixture agentName="coder-2" />
					<StagePanel title="Current" items={withCoder2.current} />
				</Wrapper>,
			);
		});

		// Slot should still be hidden (new fly-in for coder-2 is in progress)
		expect(lozenge.style.opacity).toBe("0");

		// At 300ms after first animation started (500ms total from start),
		// the FIRST animation's timeout fires. It must NOT reveal the slot.
		await act(async () => {
			vi.advanceTimersByTime(300);
		});

		// BUG: Without fix, the first timeout clears pendingFlyIns for this story,
		// revealing the slot while coder-2's fly-in is still in progress.
		expect(lozenge.style.opacity).toBe("0");
	});

	it("slot lozenge reveals correctly after the LAST animation completes", async () => {
		const empty = makePipeline();
		const withCoder1 = makePipeline({
			current: [
				{
					story_id: "137_reveal_last",
					name: "Reveal Last",
					error: null,
					merge_failure: null,
					agent: { agent_name: "coder-1", model: null, status: "running" },
					review_hold: null,
					qa: null,
					depends_on: null,
				},
			],
		});
		const withCoder2 = makePipeline({
			current: [
				{
					story_id: "137_reveal_last",
					name: "Reveal Last",
					error: null,
					merge_failure: null,
					agent: { agent_name: "coder-2", model: null, status: "running" },
					review_hold: null,
					qa: null,
					depends_on: null,
				},
			],
		});

		const { rerender } = render(
			<Wrapper pipeline={empty}>
				<RosterFixture agentName="coder-1" />
				<RosterFixture agentName="coder-2" />
				<StagePanel title="Current" items={[]} />
			</Wrapper>,
		);

		// First animation
		await act(async () => {
			rerender(
				<Wrapper pipeline={withCoder1}>
					<RosterFixture agentName="coder-1" />
					<RosterFixture agentName="coder-2" />
					<StagePanel title="Current" items={withCoder1.current} />
				</Wrapper>,
			);
		});

		// Swap at 200ms
		await act(async () => {
			vi.advanceTimersByTime(200);
		});

		await act(async () => {
			rerender(
				<Wrapper pipeline={withCoder2}>
					<RosterFixture agentName="coder-1" />
					<RosterFixture agentName="coder-2" />
					<StagePanel title="Current" items={withCoder2.current} />
				</Wrapper>,
			);
		});

		const lozenge = screen.getByTestId("slot-lozenge-137_reveal_last");

		// After the second animation's full 500ms, slot should reveal
		await act(async () => {
			vi.advanceTimersByTime(600);
		});

		expect(lozenge.style.opacity).toBe("1");
	});
});

describe("Bug 137: animations remain functional through sustained agent activity", () => {
	beforeEach(() => {
		vi.useFakeTimers();
		Element.prototype.getBoundingClientRect = vi.fn().mockReturnValue({
			left: 100,
			top: 50,
			right: 180,
			bottom: 70,
			width: 80,
			height: 20,
			x: 100,
			y: 50,
			toJSON: () => ({}),
		});
		vi.spyOn(window, "requestAnimationFrame").mockImplementation((cb) => {
			cb(0);
			return 0;
		});
	});

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

	it("fly-in still works after multiple rapid swaps have completed", async () => {
		const empty = makePipeline();
		const makeWith = (agentName: string) =>
			makePipeline({
				current: [
					{
						story_id: "137_sustained",
						name: "Sustained",
						error: null,
						merge_failure: null,
						agent: { agent_name: agentName, model: null, status: "running" },
						review_hold: null,
						qa: null,
						depends_on: null,
					},
				],
			});

		const { rerender } = render(
			<Wrapper pipeline={empty}>
				<RosterFixture agentName="coder-1" />
				<RosterFixture agentName="coder-2" />
				<RosterFixture agentName="coder-3" />
				<StagePanel title="Current" items={[]} />
			</Wrapper>,
		);

		// Rapid-fire: assign coder-1, then swap to coder-2 at 100ms
		const p1 = makeWith("coder-1");
		await act(async () => {
			rerender(
				<Wrapper pipeline={p1}>
					<RosterFixture agentName="coder-1" />
					<RosterFixture agentName="coder-2" />
					<RosterFixture agentName="coder-3" />
					<StagePanel title="Current" items={p1.current} />
				</Wrapper>,
			);
		});

		await act(async () => {
			vi.advanceTimersByTime(100);
		});

		const p2 = makeWith("coder-2");
		await act(async () => {
			rerender(
				<Wrapper pipeline={p2}>
					<RosterFixture agentName="coder-1" />
					<RosterFixture agentName="coder-2" />
					<RosterFixture agentName="coder-3" />
					<StagePanel title="Current" items={p2.current} />
				</Wrapper>,
			);
		});

		// Let all animations complete
		await act(async () => {
			vi.advanceTimersByTime(1000);
		});

		const lozenge = screen.getByTestId("slot-lozenge-137_sustained");
		expect(lozenge.style.opacity).toBe("1");

		// Now assign coder-3 — a fresh fly-in should still work
		const p3 = makeWith("coder-3");
		await act(async () => {
			rerender(
				<Wrapper pipeline={p3}>
					<RosterFixture agentName="coder-1" />
					<RosterFixture agentName="coder-2" />
					<RosterFixture agentName="coder-3" />
					<StagePanel title="Current" items={p3.current} />
				</Wrapper>,
			);
		});

		// Slot should be hidden again for the new fly-in
		expect(lozenge.style.opacity).toBe("0");

		// A flying clone should exist
		const clone = document.body.querySelector(
			'[data-testid^="flying-lozenge-fly-in"]',
		);
		expect(clone).not.toBeNull();

		// After animation completes, slot reveals
		await act(async () => {
			vi.advanceTimersByTime(600);
		});

		expect(lozenge.style.opacity).toBe("1");
	});
});
