import { test, expect, describe, beforeEach, afterEach } from "bun:test"
import { join } from "path"
import { push } from "./push"
import {
	createTempDir,
	cleanupTempDir,
	initGitRepo,
	getCurrentBranch,
	getGitOutput,
	createCommit,
	checkoutBranch,
	createBranch,
	addAndCommit,
	renameBranch,
	runTestEffect,
	runTestEffectWithMockFilterRepo,
} from "../test-utils"

async function setupAgencyJson(gitRoot: string): Promise<void> {
	const agencyJson = {
		version: 1,
		injectedFiles: ["AGENTS.MD", "TASK.md"],
		template: "test",
		createdAt: new Date().toISOString(),
	}
	await Bun.write(
		join(gitRoot, "agency.json"),
		JSON.stringify(agencyJson, null, 2) + "\n",
	)
	await addAndCommit(gitRoot, "agency.json", "Add agency.json")
}

async function setupBareRemote(): Promise<string> {
	// Create a bare repository outside the working tree to avoid polluting git status
	const remoteDir = await createTempDir()
	await Bun.spawn(["git", "init", "--bare", remoteDir], {
		stdout: "pipe",
		stderr: "pipe",
	}).exited
	return remoteDir
}

async function checkoutNonAgencyBranch(gitRoot: string): Promise<void> {
	const proc = Bun.spawn(
		["git", "checkout", "-q", "-b", "plain-feature", "origin/main"],
		{
			cwd: gitRoot,
			stdout: "pipe",
			stderr: "pipe",
		},
	)
	const exitCode = await proc.exited
	if (exitCode !== 0) {
		throw new Error(await new Response(proc.stderr).text())
	}
}

describe("push command", () => {
	let tempDir: string
	let remoteDir: string
	let originalCwd: string

	beforeEach(async () => {
		tempDir = await createTempDir()
		originalCwd = process.cwd()
		process.chdir(tempDir)

		// Set config path to non-existent file to use defaults
		process.env.AGENCY_CONFIG_PATH = join(tempDir, "non-existent-config.json")

		// Initialize git repo
		await initGitRepo(tempDir)
		await createCommit(tempDir, "Initial commit")

		// Rename to main if needed
		const currentBranch = await getCurrentBranch(tempDir)
		if (currentBranch === "master") {
			await renameBranch(tempDir, "main")
		}

		// Setup bare remote and push main
		remoteDir = await setupBareRemote()
		await Bun.spawn(["git", "remote", "add", "origin", remoteDir], {
			cwd: tempDir,
			stdout: "pipe",
			stderr: "pipe",
		}).exited
		await Bun.spawn(["git", "push", "-u", "origin", "main"], {
			cwd: tempDir,
			stdout: "pipe",
			stderr: "pipe",
		}).exited

		// Setup agency.json
		await setupAgencyJson(tempDir)

		// Create a source branch (with agency-- prefix per new default config)
		await createBranch(tempDir, "agency--feature")
		await createCommit(tempDir, "Feature work")
	})

	afterEach(async () => {
		process.chdir(originalCwd)
		delete process.env.AGENCY_CONFIG_PATH
		await cleanupTempDir(tempDir)
		await cleanupTempDir(remoteDir)
	})

	describe("basic functionality", () => {
		test("falls back to git push outside agency context", async () => {
			await checkoutNonAgencyBranch(tempDir)
			await createCommit(tempDir, "Plain feature work")
			await Bun.write(join(tempDir, "uncommitted.txt"), "uncommitted\n")

			await runTestEffect(
				push({
					gitArgs: ["origin", "plain-feature"],
					silent: true,
				}),
			)

			const localHead = await getGitOutput(tempDir, ["rev-parse", "HEAD"])
			const remoteHead = await getGitOutput(tempDir, [
				"rev-parse",
				"refs/remotes/origin/plain-feature",
			])
			expect(remoteHead.trim()).toBe(localHead.trim())
			expect(await Bun.file(join(tempDir, "uncommitted.txt")).exists()).toBe(
				true,
			)
		})

		test("forwards --no-verify to the git push fallback", async () => {
			await checkoutNonAgencyBranch(tempDir)
			await createCommit(tempDir, "Plain feature work")

			await Bun.spawn(
				["git", "config", "core.hooksPath", join(tempDir, ".git", "hooks")],
				{
					cwd: tempDir,
					stdout: "pipe",
					stderr: "pipe",
				},
			).exited
			const hookPath = join(tempDir, ".git", "hooks", "pre-push")
			await Bun.write(hookPath, "#!/bin/sh\nexit 1\n")
			await Bun.spawn(["chmod", "+x", hookPath], {
				cwd: tempDir,
				stdout: "pipe",
				stderr: "pipe",
			}).exited

			await runTestEffect(
				push({
					gitArgs: ["origin", "plain-feature"],
					noVerify: true,
					silent: true,
				}),
			)

			const remoteBranches = await getGitOutput(tempDir, [
				"ls-remote",
				"--heads",
				"origin",
				"plain-feature",
			])
			expect(remoteBranches).toContain("plain-feature")
		})

		test("forwards force modes to the git push fallback", async () => {
			await checkoutNonAgencyBranch(tempDir)
			await createCommit(tempDir, "Initial plain feature work")
			await Bun.spawn(["git", "push", "-u", "origin", "plain-feature"], {
				cwd: tempDir,
				stdout: "pipe",
				stderr: "pipe",
			}).exited

			await createCommit(tempDir, "Remote state for lease")
			await Bun.spawn(["git", "push"], {
				cwd: tempDir,
				stdout: "pipe",
				stderr: "pipe",
			}).exited
			await Bun.spawn(["git", "reset", "--hard", "HEAD~1"], {
				cwd: tempDir,
				stdout: "pipe",
				stderr: "pipe",
			}).exited
			await createCommit(tempDir, "Lease-protected rewrite")

			await runTestEffect(push({ forceWithLease: true, silent: true }))

			await createCommit(tempDir, "Remote state for force")
			await Bun.spawn(["git", "push"], {
				cwd: tempDir,
				stdout: "pipe",
				stderr: "pipe",
			}).exited
			await Bun.spawn(["git", "reset", "--hard", "HEAD~1"], {
				cwd: tempDir,
				stdout: "pipe",
				stderr: "pipe",
			}).exited
			await createCommit(tempDir, "Forced rewrite")

			await runTestEffect(push({ force: true, silent: true }))

			const localHead = await getGitOutput(tempDir, ["rev-parse", "HEAD"])
			const remoteHead = await getGitOutput(tempDir, [
				"ls-remote",
				"origin",
				"refs/heads/plain-feature",
			])
			expect(remoteHead.split("\t")[0]).toBe(localHead.trim())
		})

		test("fails when there are uncommitted changes", async () => {
			// Create uncommitted changes
			await Bun.write(join(tempDir, "uncommitted.txt"), "uncommitted\n")

			expect(
				runTestEffect(
					push({ baseBranch: "main", silent: true, skipFilter: true }),
				),
			).rejects.toThrow(/uncommitted changes/)
		})

		test("fails when there are staged but uncommitted changes", async () => {
			// Create a staged but uncommitted change
			await Bun.write(join(tempDir, "staged.txt"), "staged\n")
			await Bun.spawn(["git", "add", "staged.txt"], {
				cwd: tempDir,
				stdout: "pipe",
				stderr: "pipe",
			}).exited

			expect(
				runTestEffect(
					push({ baseBranch: "main", silent: true, skipFilter: true }),
				),
			).rejects.toThrow(/uncommitted changes/)
		})

		test("creates emit branch, pushes it, and returns to source", async () => {
			// We're on agency--feature branch (source)
			expect(await getCurrentBranch(tempDir)).toBe("agency--feature")

			// Run push command
			await runTestEffect(
				push({ baseBranch: "main", silent: true, skipFilter: true }),
			)

			// Should be back on agency--feature branch (source)
			expect(await getCurrentBranch(tempDir)).toBe("agency--feature")

			// emit branch (feature) should exist locally and on remote
			const branches = await getGitOutput(tempDir, ["branch"])
			expect(branches).toContain("feature")

			const remoteBranches = await getGitOutput(tempDir, [
				"ls-remote",
				"--heads",
				"origin",
				"feature",
			])
			expect(remoteBranches).toContain("feature")
		})

		test("forwards verbose output from emit step", async () => {
			const originalLog = console.log
			const logMessages: string[] = []
			console.log = (msg: string) => {
				logMessages.push(msg)
			}

			try {
				await runTestEffect(
					push({
						baseBranch: "main",
						verbose: true,
						skipFilter: true,
					}),
				)
			} finally {
				console.log = originalLog
			}

			expect(logMessages).toContain("Step 1: Emitting...")
			expect(
				logMessages.some(
					(msg) => msg.includes("Using base branch:") && msg.includes("main"),
				),
			).toBe(true)
			expect(
				logMessages.some((msg) =>
					msg.includes("Skipping git-filter-repo (skipFilter=true)"),
				),
			).toBe(true)
			expect(logMessages.some((msg) => msg.includes("Emitted"))).toBe(true)
		})

		test("works with custom branch name", async () => {
			await runTestEffect(
				push({
					baseBranch: "main",
					emit: "custom-pr-branch",
					silent: true,
					skipFilter: true,
				}),
			)

			// Should be back on agency--feature branch (source)
			expect(await getCurrentBranch(tempDir)).toBe("agency--feature")

			// Custom branch should exist
			const branches = await getGitOutput(tempDir, ["branch"])
			expect(branches).toContain("custom-pr-branch")
		})

		test("recreates emit branch if it already exists", async () => {
			// First push
			await runTestEffect(
				push({ baseBranch: "main", silent: true, skipFilter: true }),
			)

			// Make more changes on source branch
			await checkoutBranch(tempDir, "agency--feature")
			await createCommit(tempDir, "More feature work")

			// Second push should recreate the emit branch
			await runTestEffect(
				push({ baseBranch: "main", silent: true, skipFilter: true }),
			)

			// Should still be back on agency--feature branch (source)
			expect(await getCurrentBranch(tempDir)).toBe("agency--feature")
		})
	})

	describe("error handling", () => {
		test("throws when emitted CLAUDE.md references AGENCY.md", async () => {
			await Bun.write(
				join(tempDir, "CLAUDE.md"),
				"# Instructions\n\n@AGENCY.md\n",
			)
			await addAndCommit(tempDir, "CLAUDE.md", "Add Claude agency reference")

			await expect(
				runTestEffectWithMockFilterRepo(
					push({ baseBranch: "main", silent: true }),
				),
			).rejects.toThrow(
				"Failed to create emit branch: CLAUDE.md on emitted branch feature still references @AGENCY.md",
			)
		})

		test("switches to source branch when run from emit branch", async () => {
			// First create the emit branch from source branch
			await runTestEffect(
				push({ baseBranch: "main", silent: true, skipFilter: true }),
			)

			// Now we're on agency--feature, switch to the emit branch (feature)
			await checkoutBranch(tempDir, "feature")

			// Verify we're on the emit branch
			expect(await getCurrentBranch(tempDir)).toBe("feature")

			// Make a change on source branch that we'll push
			await checkoutBranch(tempDir, "agency--feature")
			await createCommit(tempDir, "Another feature commit")

			// Switch back to emit branch
			await checkoutBranch(tempDir, "feature")

			// Run push from emit branch - should detect we're on emit branch,
			// switch to source (agency--feature), and continue
			await runTestEffect(
				push({ baseBranch: "main", silent: true, skipFilter: true }),
			)

			// Should be back on agency--feature branch (the source branch)
			expect(await getCurrentBranch(tempDir)).toBe("agency--feature")
		})

		test("throws error when not in a git repository", async () => {
			const nonGitDir = await createTempDir()
			process.chdir(nonGitDir)

			await expect(
				runTestEffect(
					push({ baseBranch: "main", silent: true, skipFilter: true }),
				),
			).rejects.toThrow("Not in a git repository")

			await cleanupTempDir(nonGitDir)
		})

		test("handles push failure gracefully", async () => {
			// Remove the remote to cause push to fail
			await Bun.spawn(["git", "remote", "remove", "origin"], {
				cwd: tempDir,
				stdout: "pipe",
				stderr: "pipe",
			}).exited

			// Push should fail because no remote exists
			await expect(
				runTestEffect(
					push({ baseBranch: "main", silent: true, skipFilter: true }),
				),
			).rejects.toThrow(/No git remotes found/)
		})
	})

	describe("silent mode", () => {
		test("silent flag suppresses output", async () => {
			// Capture output
			const originalLog = console.log
			let logCalled = false
			console.log = () => {
				logCalled = true
			}

			await runTestEffect(
				push({ baseBranch: "main", silent: true, skipFilter: true }),
			)

			console.log = originalLog
			expect(logCalled).toBe(false)
		})
	})

	describe("force push", () => {
		test("force pushes when branch has diverged and --force is provided", async () => {
			// First push
			await runTestEffect(
				push({ baseBranch: "main", silent: true, skipFilter: true }),
			)

			// Make changes on source branch
			await checkoutBranch(tempDir, "agency--feature")
			await createCommit(tempDir, "More feature work")

			// Modify the emit branch to create divergence
			await checkoutBranch(tempDir, "feature")
			await createCommit(tempDir, "Direct emit branch commit")
			await Bun.spawn(["git", "push"], {
				cwd: tempDir,
				stdout: "pipe",
				stderr: "pipe",
			}).exited

			// Go back to source branch and try to push again with --force
			await checkoutBranch(tempDir, "agency--feature")

			// Capture output to check for force push message
			const originalLog = console.log
			let logMessages: string[] = []
			console.log = (msg: string) => {
				logMessages.push(msg)
			}

			await runTestEffect(
				push({
					baseBranch: "main",
					force: true,
					silent: false,
					skipFilter: true,
				}),
			)

			console.log = originalLog

			// Should be back on agency--feature branch (source)
			expect(await getCurrentBranch(tempDir)).toBe("agency--feature")

			// Should have reported force push
			expect(logMessages.some((msg) => msg.includes("(forced)"))).toBe(true)
		})

		test("suggests using --force when push is rejected without it", async () => {
			// First push
			await runTestEffect(
				push({ baseBranch: "main", silent: true, skipFilter: true }),
			)

			// Make changes on source branch
			await checkoutBranch(tempDir, "agency--feature")
			await createCommit(tempDir, "More feature work")

			// Modify the emit branch to create divergence
			await checkoutBranch(tempDir, "feature")
			await createCommit(tempDir, "Direct emit branch commit")
			await Bun.spawn(["git", "push"], {
				cwd: tempDir,
				stdout: "pipe",
				stderr: "pipe",
			}).exited

			// Go back to source branch and try to push without --force
			await checkoutBranch(tempDir, "agency--feature")

			// Should throw error suggesting --force
			await expect(
				runTestEffect(
					push({ baseBranch: "main", silent: true, skipFilter: true }),
				),
			).rejects.toThrow(
				/non-fast-forward update.*agency push --force|agency push --force.*non-fast-forward update/s,
			)

			// Should still be on agency--feature branch (not left in intermediate state)
			expect(await getCurrentBranch(tempDir)).toBe("agency--feature")
		})

		test("reports pre-push hook output without suggesting --force", async () => {
			await Bun.spawn(
				["git", "config", "core.hooksPath", join(tempDir, ".git", "hooks")],
				{
					cwd: tempDir,
					stdout: "pipe",
					stderr: "pipe",
				},
			).exited
			const hookPath = join(tempDir, ".git", "hooks", "pre-push")
			await Bun.write(
				hookPath,
				[
					"#!/bin/sh",
					"printf 'pre-push hook stdout failure\\n'",
					"printf 'pre-push hook stderr failure\\n' >&2",
					"exit 1",
					"",
				].join("\n"),
			)
			await Bun.spawn(["chmod", "+x", hookPath], {
				cwd: tempDir,
				stdout: "pipe",
				stderr: "pipe",
			}).exited

			let error: unknown
			try {
				await runTestEffect(
					push({ baseBranch: "main", silent: true, skipFilter: true }),
				)
			} catch (caught) {
				error = caught
			}

			expect(error).toBeInstanceOf(Error)
			const message = error instanceof Error ? error.message : String(error)
			expect(message).toContain("pre-push hook stdout failure")
			expect(message).toContain("pre-push hook stderr failure")
			expect(message).not.toContain("agency push --force")

			expect(await getCurrentBranch(tempDir)).toBe("agency--feature")
		})

		test("does not report force push when --force is provided but not needed", async () => {
			// Capture output to check for force push message
			const originalLog = console.log
			let logMessages: string[] = []
			console.log = (msg: string) => {
				logMessages.push(msg)
			}

			// First push with --force (but it won't actually need force)
			await runTestEffect(
				push({
					baseBranch: "main",
					force: true,
					silent: false,
					skipFilter: true,
				}),
			)

			console.log = originalLog

			// Should be back on agency--feature branch (source)
			expect(await getCurrentBranch(tempDir)).toBe("agency--feature")

			// Should NOT have reported force push (since it wasn't actually used)
			expect(logMessages.some((msg) => msg.includes("Force pushed"))).toBe(
				false,
			)
			expect(logMessages.some((msg) => msg.includes("Pushed"))).toBe(true)
		})
	})

	describe("--pr flag", () => {
		test("handles gh CLI failure gracefully and continues", async () => {
			// Capture error output
			const originalError = console.error
			let errorMessages: string[] = []
			console.error = (msg: string) => {
				errorMessages.push(msg)
			}

			// Should not throw - command should complete despite gh failure
			// (gh will fail in test environment because there's no GitHub remote)
			await runTestEffect(
				push({ baseBranch: "main", pr: true, silent: true, skipFilter: true }),
			)

			console.error = originalError

			// Should be back on agency--feature branch (command completes despite gh failure)
			expect(await getCurrentBranch(tempDir)).toBe("agency--feature")

			// Should have warned about gh failure
			expect(
				errorMessages.some((msg) => msg.includes("Failed to open GitHub PR")),
			).toBe(true)
		})

		test("does not call gh when --pr flag is not set", async () => {
			// Capture error output
			const originalError = console.error
			let errorMessages: string[] = []
			console.error = (msg: string) => {
				errorMessages.push(msg)
			}

			// Push without --pr flag
			await runTestEffect(
				push({ baseBranch: "main", silent: true, skipFilter: true }),
			)

			console.error = originalError

			// Should be back on agency--feature branch (source)
			expect(await getCurrentBranch(tempDir)).toBe("agency--feature")

			// gh should NOT have been called (no error about GitHub PR)
			expect(errorMessages.some((msg) => msg.includes("GitHub PR"))).toBe(false)
		})
	})
})
