import assert from "node:assert/strict";
import { execFileSync, spawn } from "node:child_process";
import { randomUUID } from "node:crypto";
import {
	existsSync,
	mkdirSync,
	mkdtempSync,
	readFileSync,
	rmSync,
	writeFileSync,
} from "node:fs";
import { homedir, tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { afterAll, afterEach, beforeAll, describe, it } from "vitest";
import { loadConfig, resolveConfigPath, validateConfig } from "./config.js";
import { Daemon } from "./daemon.js";
import { Logger } from "./logger.js";
import { SessionManager } from "./session-manager.js";
import type { DaemonConfig, LogEntry } from "./types.js";

// ---------- helpers ----------

function tmpDir(): string {
	return mkdtempSync(
		join(tmpdir(), `daemon-test-${randomUUID().slice(0, 8)}-`),
	);
}

const cleanupDirs: string[] = [];
afterEach(() => {
	while (cleanupDirs.length) {
		const d = cleanupDirs.pop()!;
		if (existsSync(d)) rmSync(d, { recursive: true, force: true });
	}
});

// ---------- config ----------

describe("resolveConfigPath", () => {
	it("prefers explicit CLI path", () => {
		const p = resolveConfigPath("/custom/config.yaml");
		assert.equal(p, "/custom/config.yaml");
	});

	it("expands ~ in CLI path", () => {
		const p = resolveConfigPath("~/my-daemon.yaml");
		assert.ok(p.startsWith(homedir()));
		assert.ok(p.endsWith("my-daemon.yaml"));
	});

	it("falls back to SF_DAEMON_CONFIG env var", () => {
		const prev = process.env["SF_DAEMON_CONFIG"];
		try {
			process.env["SF_DAEMON_CONFIG"] = "/env/path.yaml";
			const p = resolveConfigPath();
			assert.equal(p, "/env/path.yaml");
		} finally {
			if (prev === undefined) delete process.env["SF_DAEMON_CONFIG"];
			else process.env["SF_DAEMON_CONFIG"] = prev;
		}
	});

	it("defaults to ~/.sf/daemon.yaml", () => {
		const prev = process.env["SF_DAEMON_CONFIG"];
		try {
			delete process.env["SF_DAEMON_CONFIG"];
			const p = resolveConfigPath();
			assert.equal(p, join(homedir(), ".sf", "daemon.yaml"));
		} finally {
			if (prev !== undefined) process.env["SF_DAEMON_CONFIG"] = prev;
		}
	});
});

describe("loadConfig", () => {
	// Save and clear DISCORD_BOT_TOKEN for this suite — env override interferes with file-token assertions
	let savedToken: string | undefined;
	beforeAll(() => {
		savedToken = process.env["DISCORD_BOT_TOKEN"];
		delete process.env["DISCORD_BOT_TOKEN"];
	});
	afterEach(() => {}); // cleanup dirs handled by top-level afterEach
	// Restore after all tests in this suite
	afterAll(() => {
		if (savedToken !== undefined) process.env["DISCORD_BOT_TOKEN"] = savedToken;
	});

	it("parses valid YAML config", () => {
		const dir = tmpDir();
		cleanupDirs.push(dir);
		const configPath = join(dir, "daemon.yaml");
		writeFileSync(
			configPath,
			`
discord:
  token: "test-token-123"
  guild_id: "g1"
  owner_id: "o1"
projects:
  scan_roots:
    - ~/projects
    - /absolute/path
log:
  file: ~/logs/daemon.log
  level: debug
  max_size_mb: 100
`,
		);
		const cfg = loadConfig(configPath);
		assert.equal(cfg.discord?.token, "test-token-123");
		assert.equal(cfg.discord?.guild_id, "g1");
		assert.equal(cfg.log.level, "debug");
		assert.equal(cfg.log.max_size_mb, 100);
		assert.ok(cfg.log.file.startsWith(homedir()));
		assert.ok(cfg.projects.scan_roots[0]!.startsWith(homedir()));
		assert.equal(cfg.projects.scan_roots[1], "/absolute/path");
	});

	it("returns defaults when config file is missing", () => {
		const cfg = loadConfig("/nonexistent/path/daemon.yaml");
		assert.equal(cfg.log.level, "info");
		assert.equal(cfg.log.max_size_mb, 50);
		assert.ok(cfg.log.file.endsWith("daemon.log"));
		assert.deepEqual(cfg.projects.scan_roots, []);
		assert.equal(cfg.discord, undefined);
	});

	it("throws on malformed YAML", () => {
		const dir = tmpDir();
		cleanupDirs.push(dir);
		const configPath = join(dir, "bad.yaml");
		writeFileSync(configPath, ":\n  :\n    bad: [unclosed");
		assert.throws(
			() => loadConfig(configPath),
			(err: unknown) => {
				assert.ok(err instanceof Error);
				assert.ok(err.message.includes("Failed to parse YAML"));
				assert.ok(err.message.includes(configPath));
				return true;
			},
		);
	});

	it("returns defaults for empty YAML file", () => {
		const dir = tmpDir();
		cleanupDirs.push(dir);
		const configPath = join(dir, "empty.yaml");
		writeFileSync(configPath, "");
		const cfg = loadConfig(configPath);
		assert.equal(cfg.log.level, "info");
		assert.equal(cfg.log.max_size_mb, 50);
		assert.deepEqual(cfg.projects.scan_roots, []);
	});
});

describe("validateConfig", () => {
	// Save and clear DISCORD_BOT_TOKEN for tests that don't expect it
	let savedToken: string | undefined;
	beforeAll(() => {
		savedToken = process.env["DISCORD_BOT_TOKEN"];
		delete process.env["DISCORD_BOT_TOKEN"];
	});
	afterAll(() => {
		if (savedToken !== undefined) process.env["DISCORD_BOT_TOKEN"] = savedToken;
	});

	it("fills remaining defaults for partial config", () => {
		const cfg = validateConfig({ projects: { scan_roots: ["/a"] } });
		assert.equal(cfg.log.level, "info");
		assert.equal(cfg.log.max_size_mb, 50);
		assert.ok(cfg.log.file.endsWith("daemon.log"));
		assert.deepEqual(cfg.projects.scan_roots, ["/a"]);
		assert.equal(cfg.swarms?.auto_sync, true);
		assert.equal(cfg.swarms?.auto_start, false);
		assert.ok(cfg.swarms?.registry_path.endsWith("swarms.json"));
		assert.equal(cfg.discord, undefined);
	});

	it("parses swarms registry sync config", () => {
		const cfg = validateConfig({
			swarms: {
				registry_path: "~/custom-swarms.json",
				auto_sync: false,
				auto_start: true,
				refresh_ms: 5_000,
			},
		});
		assert.equal(cfg.swarms?.auto_sync, false);
		assert.equal(cfg.swarms?.auto_start, true);
		assert.equal(cfg.swarms?.refresh_ms, 5_000);
		assert.ok(cfg.swarms?.registry_path.startsWith(homedir()));
		assert.ok(cfg.swarms?.registry_path.endsWith("custom-swarms.json"));
	});

	it("falls back to info for invalid log level", () => {
		const cfg = validateConfig({ log: { level: "trace" } });
		assert.equal(cfg.log.level, "info");
	});

	it("returns full defaults for null input", () => {
		const cfg = validateConfig(null);
		assert.equal(cfg.log.level, "info");
		assert.equal(cfg.log.max_size_mb, 50);
	});

	it("returns full defaults for non-object input", () => {
		const cfg = validateConfig("not-an-object");
		assert.equal(cfg.log.level, "info");
	});

	it("expands ~ in log file path", () => {
		const cfg = validateConfig({ log: { file: "~/my.log" } });
		assert.ok(cfg.log.file.startsWith(homedir()));
		assert.ok(cfg.log.file.endsWith("my.log"));
	});

	it("overrides discord token from DISCORD_BOT_TOKEN env var", () => {
		const prev = process.env["DISCORD_BOT_TOKEN"];
		try {
			process.env["DISCORD_BOT_TOKEN"] = "env-override-token";
			const cfg = validateConfig({
				discord: { token: "file-token", guild_id: "g1", owner_id: "o1" },
			});
			assert.equal(cfg.discord?.token, "env-override-token");
			assert.equal(cfg.discord?.guild_id, "g1");
		} finally {
			if (prev === undefined) delete process.env["DISCORD_BOT_TOKEN"];
			else process.env["DISCORD_BOT_TOKEN"] = prev;
		}
	});

	it("creates discord block from env var even when absent in config", () => {
		const prev = process.env["DISCORD_BOT_TOKEN"];
		try {
			process.env["DISCORD_BOT_TOKEN"] = "env-only-token";
			const cfg = validateConfig({});
			assert.equal(cfg.discord?.token, "env-only-token");
		} finally {
			if (prev === undefined) delete process.env["DISCORD_BOT_TOKEN"];
			else process.env["DISCORD_BOT_TOKEN"] = prev;
		}
	});
});

// ---------- logger ----------

describe("Logger", () => {
	it("writes JSON-lines entries to file", async () => {
		const dir = tmpDir();
		cleanupDirs.push(dir);
		const logPath = join(dir, "test.log");

		const logger = new Logger({ filePath: logPath, level: "debug" });
		logger.info("hello world");
		logger.debug("detail", { key: "val" });
		await logger.close();

		const lines = readFileSync(logPath, "utf-8").trim().split("\n");
		assert.equal(lines.length, 2);

		const entry0: LogEntry = JSON.parse(lines[0]!);
		assert.equal(entry0.level, "info");
		assert.equal(entry0.msg, "hello world");
		assert.ok(entry0.ts); // ISO-8601

		const entry1: LogEntry = JSON.parse(lines[1]!);
		assert.equal(entry1.level, "debug");
		assert.equal(entry1.msg, "detail");
		assert.deepEqual(entry1.data, { key: "val" });
	});

	it("filters entries below configured level", async () => {
		const dir = tmpDir();
		cleanupDirs.push(dir);
		const logPath = join(dir, "filter.log");

		const logger = new Logger({ filePath: logPath, level: "warn" });
		logger.debug("should not appear");
		logger.info("should not appear either");
		logger.warn("visible warning");
		logger.error("visible error");
		await logger.close();

		const lines = readFileSync(logPath, "utf-8").trim().split("\n");
		assert.equal(lines.length, 2);
		assert.equal((JSON.parse(lines[0]!) as LogEntry).level, "warn");
		assert.equal((JSON.parse(lines[1]!) as LogEntry).level, "error");
	});

	it("close() resolves after stream ends", async () => {
		const dir = tmpDir();
		cleanupDirs.push(dir);
		const logPath = join(dir, "close.log");

		const logger = new Logger({ filePath: logPath, level: "info" });
		logger.info("before close");
		await logger.close();

		// File should be readable and contain the entry
		const content = readFileSync(logPath, "utf-8");
		assert.ok(content.includes("before close"));
	});

	it("creates parent directories if they do not exist", async () => {
		const dir = tmpDir();
		cleanupDirs.push(dir);
		const logPath = join(dir, "nested", "deep", "test.log");

		const logger = new Logger({ filePath: logPath, level: "info" });
		logger.info("nested dir test");
		await logger.close();

		assert.ok(existsSync(logPath));
		const content = readFileSync(logPath, "utf-8");
		assert.ok(content.includes("nested dir test"));
	});

	it("does not include data field when not provided", async () => {
		const dir = tmpDir();
		cleanupDirs.push(dir);
		const logPath = join(dir, "nodata.log");

		const logger = new Logger({ filePath: logPath, level: "info" });
		logger.info("no extra data");
		await logger.close();

		const entry: LogEntry = JSON.parse(readFileSync(logPath, "utf-8").trim());
		assert.equal(entry.data, undefined);
		// Also verify the raw JSON doesn't contain "data" key
		assert.ok(!readFileSync(logPath, "utf-8").includes('"data"'));
	});
});

// ---------- token safety ----------

describe("token safety", () => {
	it("discord token never appears in log output", async () => {
		const dir = tmpDir();
		cleanupDirs.push(dir);
		const logPath = join(dir, "token-safety.log");

		// Config with a token
		const prev = process.env["DISCORD_BOT_TOKEN"];
		try {
			process.env["DISCORD_BOT_TOKEN"] = "super-secret-token-value";
			const cfg = validateConfig({});

			const logger = new Logger({ filePath: logPath, level: "debug" });
			// Log the config object — token must not leak
			logger.info("config loaded", { discord_configured: !!cfg.discord });
			logger.debug("startup complete");
			await logger.close();

			const content = readFileSync(logPath, "utf-8");
			assert.ok(!content.includes("super-secret-token-value"));
		} finally {
			if (prev === undefined) delete process.env["DISCORD_BOT_TOKEN"];
			else process.env["DISCORD_BOT_TOKEN"] = prev;
		}
	});
});

// ---------- daemon lifecycle ----------

// Resolve the dist/ directory for spawning CLI
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);

describe("Daemon", () => {
	it("logs lifecycle events on start and shutdown", async () => {
		const dir = tmpDir();
		cleanupDirs.push(dir);
		const logPath = join(dir, "daemon-lifecycle.log");

		const config: DaemonConfig = {
			discord: undefined,
			projects: { scan_roots: ["/a", "/b"] },
			log: { file: logPath, level: "info", max_size_mb: 50 },
		};

		const logger = new Logger({ filePath: logPath, level: "info" });
		const daemon = new Daemon(config, logger);

		await daemon.start();

		// start() should have logged 'daemon started'
		// shutdown() directly — we override process.exit to prevent test runner from dying
		const origExit = process.exit;
		let exitCode: number | undefined;
		// @ts-expect-error — overriding process.exit for test
		process.exit = (code?: number) => {
			exitCode = code ?? 0;
		};
		try {
			await daemon.shutdown();
		} finally {
			process.exit = origExit;
		}

		assert.equal(exitCode, 0);

		const content = readFileSync(logPath, "utf-8");
		const lines = content.trim().split("\n");

		// First line: daemon started
		const startEntry: LogEntry = JSON.parse(lines[0]!);
		assert.equal(startEntry.msg, "daemon started");
		assert.equal(startEntry.data?.scan_roots, 2);
		assert.equal(startEntry.data?.discord_configured, false);

		// Second line: daemon shutting down
		const stopEntry: LogEntry = JSON.parse(lines[1]!);
		assert.equal(stopEntry.msg, "daemon shutting down");
	});

	it("shutdown is idempotent — second call is a no-op", async () => {
		const dir = tmpDir();
		cleanupDirs.push(dir);
		const logPath = join(dir, "idempotent.log");

		const config: DaemonConfig = {
			discord: undefined,
			projects: { scan_roots: [] },
			log: { file: logPath, level: "info", max_size_mb: 50 },
		};

		const logger = new Logger({ filePath: logPath, level: "info" });
		const daemon = new Daemon(config, logger);

		await daemon.start();

		const origExit = process.exit;
		let exitCount = 0;
		// @ts-expect-error — overriding process.exit for test
		process.exit = () => {
			exitCount++;
		};
		try {
			await daemon.shutdown();
			await daemon.shutdown(); // second call — should be no-op
		} finally {
			process.exit = origExit;
		}

		assert.equal(exitCount, 1, "process.exit should be called exactly once");

		const lines = readFileSync(logPath, "utf-8").trim().split("\n");
		const shutdownLines = lines.filter((l) => {
			const e: LogEntry = JSON.parse(l);
			return e.msg === "daemon shutting down";
		});
		assert.equal(
			shutdownLines.length,
			1,
			"shutdown log should appear exactly once",
		);
	});

	it("syncSwarms_when_auto_start_enabled_starts_missing_repo_clients", async () => {
		const dir = tmpDir();
		cleanupDirs.push(dir);
		const logPath = join(dir, "swarm-autostart.log");
		const registryPath = join(dir, "swarms.json");
		const projectA = join(dir, "project-a");
		const projectB = join(dir, "project-b");
		mkdirSync(join(projectA, ".sf"), { recursive: true });
		mkdirSync(join(projectB, ".sf"), { recursive: true });

		const config: DaemonConfig = {
			discord: undefined,
			projects: { scan_roots: [dir] },
			swarms: {
				registry_path: registryPath,
				auto_sync: false,
				auto_start: true,
				refresh_ms: 60_000,
			},
			log: { file: logPath, level: "debug", max_size_mb: 50 },
		};

		const logger = new Logger({ filePath: logPath, level: "debug" });
		const daemon = new Daemon(config, logger);
		await daemon.start();

		const manager = daemon.getSessionManager();
		const started: string[] = [];
		const originalGetSessionByDir = manager.getSessionByDir.bind(manager);
		const originalStartSession = manager.startSession.bind(manager);
		manager.getSessionByDir = ((projectDir: string) =>
			projectDir === projectA
				? ({ status: "running" } as ReturnType<typeof manager.getSessionByDir>)
				: undefined) as typeof manager.getSessionByDir;
		manager.startSession = (async (options) => {
			started.push(options.projectDir);
			return `session-${started.length}`;
		}) as typeof manager.startSession;

		try {
			await daemon.syncSwarms();
		} finally {
			manager.getSessionByDir = originalGetSessionByDir;
			manager.startSession = originalStartSession;
			const origExit = process.exit;
			// @ts-expect-error — overriding process.exit for test
			process.exit = () => {};
			try {
				await daemon.shutdown();
			} finally {
				process.exit = origExit;
			}
		}

		assert.deepEqual(started, [projectB]);
		const registry = JSON.parse(readFileSync(registryPath, "utf-8")) as {
			swarms: Array<{ path: string }>;
		};
		assert.deepEqual(
			registry.swarms.map((project) => project.path).sort(),
			[projectA, projectB].sort(),
		);
	});
});

// ---------- Health heartbeat ----------

describe("Health heartbeat", () => {
	it("logs health entry with expected fields after interval tick", async () => {
		const dir = tmpDir();
		cleanupDirs.push(dir);
		const logPath = join(dir, "health.log");

		const config: DaemonConfig = {
			discord: undefined,
			projects: { scan_roots: [] },
			log: { file: logPath, level: "info", max_size_mb: 50 },
		};

		const logger = new Logger({ filePath: logPath, level: "info" });
		// Use 50ms interval for fast test
		const daemon = new Daemon(config, logger, 50);

		await daemon.start();

		// Wait for at least one health tick
		await new Promise((r) => setTimeout(r, 120));

		const origExit = process.exit;
		// @ts-expect-error — overriding process.exit for test
		process.exit = () => {};
		try {
			await daemon.shutdown();
		} finally {
			process.exit = origExit;
		}

		const content = readFileSync(logPath, "utf-8");
		const lines = content.trim().split("\n");
		const healthLines = lines.filter((l) => {
			const e: LogEntry = JSON.parse(l);
			return e.msg === "health";
		});

		assert.ok(
			healthLines.length >= 1,
			"should have at least one health log entry",
		);

		const entry: LogEntry = JSON.parse(healthLines[0]!);
		assert.equal(entry.msg, "health");
		assert.equal(typeof entry.data?.uptime_s, "number");
		assert.equal(typeof entry.data?.active_sessions, "number");
		assert.equal(typeof entry.data?.discord_connected, "boolean");
		assert.equal(typeof entry.data?.memory_rss_mb, "number");
		assert.equal(entry.data?.discord_connected, false); // no discord configured
		assert.equal(entry.data?.active_sessions, 0); // no sessions
	});

	it("health timer is cleared on shutdown — no lingering intervals", async () => {
		const dir = tmpDir();
		cleanupDirs.push(dir);
		const logPath = join(dir, "health-cleanup.log");

		const config: DaemonConfig = {
			discord: undefined,
			projects: { scan_roots: [] },
			log: { file: logPath, level: "info", max_size_mb: 50 },
		};

		const logger = new Logger({ filePath: logPath, level: "info" });
		// Use 50ms interval
		const daemon = new Daemon(config, logger, 50);

		await daemon.start();

		// Wait for one tick
		await new Promise((r) => setTimeout(r, 80));

		const origExit = process.exit;
		// @ts-expect-error — overriding process.exit for test
		process.exit = () => {};
		try {
			await daemon.shutdown();
		} finally {
			process.exit = origExit;
		}

		// Count health entries at shutdown
		const contentAtShutdown = readFileSync(logPath, "utf-8");
		const healthCountAtShutdown = contentAtShutdown
			.trim()
			.split("\n")
			.filter((l) => JSON.parse(l).msg === "health").length;

		// Wait another interval — no new health entries should appear
		await new Promise((r) => setTimeout(r, 120));

		// Re-read (logger is closed, so file shouldn't change)
		const contentAfterWait = readFileSync(logPath, "utf-8");
		const healthCountAfterWait = contentAfterWait
			.trim()
			.split("\n")
			.filter((l) => JSON.parse(l).msg === "health").length;

		assert.equal(
			healthCountAfterWait,
			healthCountAtShutdown,
			"no new health entries should appear after shutdown",
		);
	});
});

function resolveCliPath(): string | undefined {
	const srcJs = join(__dirname, "cli.js");
	const distJs = join(__dirname, "../dist/cli.js");
	if (existsSync(srcJs)) return srcJs;
	if (existsSync(distJs)) return distJs;
	return undefined;
}

function canRunCli(): boolean {
	const cli = resolveCliPath();
	if (!cli) return false;
	try {
		execFileSync(process.execPath, [cli, "--help"], {
			encoding: "utf-8",
			timeout: 5000,
		});
		return true;
	} catch {
		return false;
	}
}

describe("CLI integration", () => {
	const cliRunnable = canRunCli();

	it("--help prints usage and exits 0", { skip: !cliRunnable }, () => {
		const result = execFileSync(
			process.execPath,
			[resolveCliPath()!, "--help"],
			{ encoding: "utf-8", timeout: 5000 },
		);
		assert.ok(result.includes("Usage: sf-server"));
		assert.ok(result.includes("Alias: sf-daemon"));
		assert.ok(result.includes("--config"));
		assert.ok(result.includes("--verbose"));
		assert.ok(result.includes("--start"));
	});

	it("starts, logs to file, and exits cleanly on SIGTERM", {
		timeout: 15000,
		skip: !cliRunnable,
	}, async () => {
		const dir = tmpDir();
		cleanupDirs.push(dir);
		const logPath = join(dir, "integration.log");
		const configPath = join(dir, "daemon.yaml");

		writeFileSync(
			configPath,
			`
projects:
  scan_roots:
    - /tmp/test-project
log:
  file: "${logPath}"
  level: info
  max_size_mb: 10
`,
		);

		// Use execFile with a wrapper script approach: spawn, wait for start, SIGTERM, verify
		const exitCode = await new Promise<number>((resolve, reject) => {
			const child = spawn(
				process.execPath,
				[resolveCliPath()!, "--config", configPath],
				{ stdio: "ignore" },
			);

			let resolved = false;
			child.on("error", (err) => {
				if (!resolved) {
					resolved = true;
					reject(err);
				}
			});
			child.on("exit", (code) => {
				if (!resolved) {
					resolved = true;
					resolve(code ?? 1);
				}
			});

			// Poll for startup, then send SIGTERM
			const poll = setInterval(() => {
				if (existsSync(logPath)) {
					const content = readFileSync(logPath, "utf-8");
					if (content.includes("daemon started")) {
						clearInterval(poll);
						child.kill("SIGTERM");
					}
				}
			}, 100);

			// Safety: kill child if it takes too long
			setTimeout(() => {
				clearInterval(poll);
				if (!resolved) {
					child.kill("SIGKILL");
					resolved = true;
					reject(new Error("timed out waiting for daemon"));
				}
			}, 10000);
		});

		assert.equal(exitCode, 0, "daemon should exit with code 0 on SIGTERM");

		// Small delay for filesystem flush
		await new Promise((r) => setTimeout(r, 100));

		// Verify log file contents
		const finalContent = readFileSync(logPath, "utf-8");
		assert.ok(
			finalContent.includes("daemon started"),
			"log should contain startup entry",
		);
		assert.ok(
			finalContent.includes("daemon shutting down"),
			"log should contain shutdown entry",
		);

		// Verify log entries are valid JSON-lines
		const lines = finalContent.trim().split("\n");
		for (const line of lines) {
			const entry: LogEntry = JSON.parse(line);
			assert.ok(entry.ts, "each entry should have a timestamp");
			assert.ok(entry.level, "each entry should have a level");
			assert.ok(entry.msg, "each entry should have a message");
		}
	});

	it("exits with code 1 on invalid config", { skip: !cliRunnable }, () => {
		const dir = tmpDir();
		cleanupDirs.push(dir);
		const configPath = join(dir, "bad.yaml");
		writeFileSync(configPath, ":\n  :\n    bad: [unclosed");

		try {
			execFileSync(
				process.execPath,
				[resolveCliPath()!, "--config", configPath],
				{ encoding: "utf-8", timeout: 5000 },
			);
			assert.fail("should have thrown");
		} catch (err: unknown) {
			// execFileSync throws on non-zero exit
			const execErr = err as { status: number; stderr: string };
			assert.equal(execErr.status, 1);
			assert.ok(execErr.stderr.includes("fatal"));
		}
	});
});

// ---------- Daemon + SessionManager integration ----------

describe("Daemon integration", () => {
	it("getSessionManager() returns SessionManager after start()", async () => {
		const dir = tmpDir();
		cleanupDirs.push(dir);
		const logPath = join(dir, "daemon-sm.log");

		const config: DaemonConfig = {
			discord: undefined,
			projects: { scan_roots: [] },
			log: { file: logPath, level: "info", max_size_mb: 50 },
		};

		const logger = new Logger({ filePath: logPath, level: "info" });
		const daemon = new Daemon(config, logger);

		await daemon.start();

		const sm = daemon.getSessionManager();
		assert.ok(sm instanceof SessionManager);

		// Clean shutdown
		const origExit = process.exit;
		// @ts-expect-error — overriding process.exit for test
		process.exit = () => {};
		try {
			await daemon.shutdown();
		} finally {
			process.exit = origExit;
		}
	});

	it("getSessionManager() throws before start()", async () => {
		const dir = tmpDir();
		cleanupDirs.push(dir);
		const logPath = join(dir, "daemon-nostart.log");

		const config: DaemonConfig = {
			discord: undefined,
			projects: { scan_roots: [] },
			log: { file: logPath, level: "info", max_size_mb: 50 },
		};

		const logger = new Logger({ filePath: logPath, level: "info" });
		const daemon = new Daemon(config, logger);

		assert.throws(
			() => daemon.getSessionManager(),
			(err: Error) => {
				assert.ok(err.message.includes("Daemon not started"));
				return true;
			},
		);

		// Close logger to prevent async write stream from hitting cleaned-up tmpdir
		await logger.close();
	});

	it("scanProjects() delegates to scanForProjects with configured roots", async () => {
		const dir = tmpDir();
		cleanupDirs.push(dir);
		const logPath = join(dir, "daemon-scan.log");

		// Create a fake project root with a project that has a .git marker
		const scanRoot = join(dir, "projects");
		mkdirSync(scanRoot);
		const projectDir = join(scanRoot, "my-project");
		mkdirSync(projectDir);
		mkdirSync(join(projectDir, ".git"));

		const config: DaemonConfig = {
			discord: undefined,
			projects: { scan_roots: [scanRoot] },
			log: { file: logPath, level: "info", max_size_mb: 50 },
		};

		const logger = new Logger({ filePath: logPath, level: "info" });
		const daemon = new Daemon(config, logger);

		await daemon.start();

		const projects = await daemon.scanProjects();
		assert.ok(projects.length >= 1);
		const found = projects.find((p) => p.name === "my-project");
		assert.ok(found);
		assert.ok(found.markers.includes("git"));

		// Clean shutdown
		const origExit = process.exit;
		// @ts-expect-error — overriding process.exit for test
		process.exit = () => {};
		try {
			await daemon.shutdown();
		} finally {
			process.exit = origExit;
		}
	});

	it("start_auto_syncs_swarm_registry_when_enabled", async () => {
		const dir = tmpDir();
		cleanupDirs.push(dir);
		const logPath = join(dir, "daemon-swarms.log");
		const registryPath = join(dir, "swarms.json");
		const scanRoot = join(dir, "projects");
		mkdirSync(scanRoot);
		const projectDir = join(scanRoot, "my-project");
		mkdirSync(projectDir);
		mkdirSync(join(projectDir, ".sf"));

		const config: DaemonConfig = {
			discord: undefined,
			projects: { scan_roots: [scanRoot] },
			swarms: {
				registry_path: registryPath,
				auto_sync: true,
				auto_start: false,
				refresh_ms: 60_000,
			},
			log: { file: logPath, level: "debug", max_size_mb: 50 },
		};

		const logger = new Logger({ filePath: logPath, level: "debug" });
		const daemon = new Daemon(config, logger);

		await daemon.start();
		const registry = JSON.parse(readFileSync(registryPath, "utf8"));
		assert.equal(registry.schemaVersion, 1);
		assert.equal(registry.swarms[0]?.path, projectDir);

		const origExit = process.exit;
		// @ts-expect-error — overriding process.exit for test
		process.exit = () => {};
		try {
			await daemon.shutdown();
		} finally {
			process.exit = origExit;
		}
	});

	it("shutdown cleans up sessionManager before closing logger", async () => {
		const dir = tmpDir();
		cleanupDirs.push(dir);
		const logPath = join(dir, "daemon-cleanup.log");

		const config: DaemonConfig = {
			discord: undefined,
			projects: { scan_roots: [] },
			log: { file: logPath, level: "info", max_size_mb: 50 },
		};

		const logger = new Logger({ filePath: logPath, level: "info" });
		const daemon = new Daemon(config, logger);

		await daemon.start();

		// Access sessionManager to verify it exists
		const sm = daemon.getSessionManager();
		assert.ok(sm);

		// Shutdown — should not throw even though sessionManager has no active sessions
		const origExit = process.exit;
		// @ts-expect-error — overriding process.exit for test
		process.exit = () => {};
		try {
			await daemon.shutdown();
		} finally {
			process.exit = origExit;
		}

		// Verify log contains both started and shutting down
		const content = readFileSync(logPath, "utf-8");
		assert.ok(content.includes("daemon started"));
		assert.ok(content.includes("daemon shutting down"));
	});
});
