import { mkdtemp, rm, mkdir, writeFile, unlink, realpath } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { openDatabase, closeDatabase } from "../../db/database.js";
import { createProject } from "../../db/repositories/projects.js";
import { getSeq } from "../../db/repositories/fs-state.js";
import { FileWatcher } from "../file-watcher.js";
// Suppress logger output in tests
vi.mock("../../utils/logger.js", () => ({
    logger: {
        debug: vi.fn(),
        info: vi.fn(),
        warn: vi.fn(),
        error: vi.fn(),
    },
}));
/** Wait for a specified number of milliseconds. */
function wait(ms) {
    return new Promise((resolve) => setTimeout(resolve, ms));
}
/** Collect fs_changed events and provide a waiter. */
function createEventCollector() {
    const events = [];
    const listeners = [];
    const callback = (event) => {
        events.push(event);
        for (const listener of listeners.splice(0)) {
            listener();
        }
    };
    const waitForEvents = (count, timeoutMs = 5000) => {
        return new Promise((resolve, reject) => {
            const timeout = setTimeout(() => {
                reject(new Error(`Timed out waiting for ${count} events, got ${events.length}`));
            }, timeoutMs);
            const check = () => {
                if (events.length >= count) {
                    clearTimeout(timeout);
                    resolve(events.slice(0, count));
                    return;
                }
                listeners.push(check);
            };
            check();
        });
    };
    return { events, callback, waitForEvents };
}
describe("FileWatcher", () => {
    let tempDir;
    let projectDir;
    let dbPath;
    let projectId;
    beforeEach(async () => {
        // Use realpath to get the canonical path (handles macOS /var -> /private/var symlink)
        const rawTempDir = await mkdtemp(join(tmpdir(), "pocketcoder-file-watcher-test-"));
        tempDir = await realpath(rawTempDir);
        projectDir = join(tempDir, "project");
        dbPath = join(tempDir, "daemon.db");
        await mkdir(projectDir);
        closeDatabase();
        openDatabase(dbPath);
        const project = createProject("test-project", projectDir);
        projectId = project.project_id;
    });
    afterEach(async () => {
        closeDatabase();
        await rm(tempDir, { recursive: true, force: true });
    });
    it("should detect file creation", async () => {
        const { callback, waitForEvents } = createEventCollector();
        const watcher = new FileWatcher({
            projectId,
            projectPath: projectDir,
            onFsChanged: callback,
            debounceMs: 50,
        });
        watcher.start();
        // Wait for chokidar to be ready
        await wait(300);
        await writeFile(join(projectDir, "new-file.ts"), "console.log('hello')");
        const events = await waitForEvents(1);
        await watcher.stop();
        expect(events).toHaveLength(1);
        expect(events[0].type).toBe("fs_changed");
        expect(events[0].sessionId).toBe(projectId);
        const createChange = events[0].payload.changes.find((c) => c.path === "new-file.ts" && c.kind === "create");
        expect(createChange).toBeDefined();
    });
    it("should detect file modification", async () => {
        // Create file before starting watcher
        await writeFile(join(projectDir, "existing.ts"), "old content");
        const { callback, waitForEvents } = createEventCollector();
        const watcher = new FileWatcher({
            projectId,
            projectPath: projectDir,
            onFsChanged: callback,
            debounceMs: 50,
        });
        watcher.start();
        await wait(300);
        await writeFile(join(projectDir, "existing.ts"), "new content");
        const events = await waitForEvents(1);
        await watcher.stop();
        const modifyChange = events[0].payload.changes.find((c) => c.path === "existing.ts" && c.kind === "modify");
        expect(modifyChange).toBeDefined();
    });
    it("should detect file deletion", async () => {
        const { callback, waitForEvents, events } = createEventCollector();
        const watcher = new FileWatcher({
            projectId,
            projectPath: projectDir,
            onFsChanged: callback,
            debounceMs: 50,
        });
        watcher.start();
        await wait(200);
        // Create file AFTER starting watcher so chokidar registers it
        await writeFile(join(projectDir, "to-delete.ts"), "will be deleted");
        // Wait for the create event
        await waitForEvents(1, 5000);
        // Now delete the file
        await unlink(join(projectDir, "to-delete.ts"));
        // Wait for the delete event
        await waitForEvents(2, 5000);
        await watcher.stop();
        // Find delete change in all events
        const allChanges = events.flatMap((e) => e.payload.changes);
        const deleteChange = allChanges.find((c) => c.path === "to-delete.ts" && c.kind === "delete");
        expect(deleteChange).toBeDefined();
    });
    it("should ignore excluded directories", async () => {
        // Create node_modules and .git before starting
        await mkdir(join(projectDir, "node_modules"));
        await mkdir(join(projectDir, ".git"));
        await mkdir(join(projectDir, "src"));
        const { events, callback } = createEventCollector();
        const watcher = new FileWatcher({
            projectId,
            projectPath: projectDir,
            onFsChanged: callback,
            debounceMs: 50,
        });
        watcher.start();
        await wait(300);
        // Write to ignored dirs
        await writeFile(join(projectDir, "node_modules", "package.json"), "{}");
        await writeFile(join(projectDir, ".git", "HEAD"), "ref");
        // Write to tracked dir
        await writeFile(join(projectDir, "src", "app.ts"), "export {}");
        // Wait for debounce to flush
        await wait(200);
        await watcher.stop();
        // Should only see the src/app.ts change
        const allChangePaths = events.flatMap((e) => e.payload.changes.map((c) => c.path));
        expect(allChangePaths).toContain("src/app.ts");
        expect(allChangePaths).not.toContain("node_modules/package.json");
        expect(allChangePaths).not.toContain(".git/HEAD");
    });
    it("should debounce rapid changes", async () => {
        const { callback, waitForEvents } = createEventCollector();
        const watcher = new FileWatcher({
            projectId,
            projectPath: projectDir,
            onFsChanged: callback,
            debounceMs: 100,
        });
        watcher.start();
        await wait(300);
        // Create multiple files rapidly
        await writeFile(join(projectDir, "file1.ts"), "1");
        await writeFile(join(projectDir, "file2.ts"), "2");
        await writeFile(join(projectDir, "file3.ts"), "3");
        // Wait for debounce to trigger (should batch into single event)
        const events = await waitForEvents(1);
        await watcher.stop();
        // All files should be in a single event's changes
        const changePaths = events[0].payload.changes.map((c) => c.path);
        expect(changePaths).toContain("file1.ts");
        expect(changePaths).toContain("file2.ts");
        expect(changePaths).toContain("file3.ts");
    });
    it("should increment seq on each batch", async () => {
        const { callback, waitForEvents } = createEventCollector();
        const watcher = new FileWatcher({
            projectId,
            projectPath: projectDir,
            onFsChanged: callback,
            debounceMs: 50,
        });
        watcher.start();
        await wait(300);
        // Create first file, wait for flush
        await writeFile(join(projectDir, "first.ts"), "1");
        const firstEvents = await waitForEvents(1);
        // Wait for debounce to complete before next batch
        await wait(150);
        // Create second file
        await writeFile(join(projectDir, "second.ts"), "2");
        const allEvents = await waitForEvents(2);
        await watcher.stop();
        expect(firstEvents[0].payload.seq).toBe(1);
        expect(allEvents[1].payload.seq).toBe(2);
        // Verify seq is persisted in DB
        const dbSeq = getSeq(projectId);
        expect(dbSeq).toBe(2);
    });
    it("should truncate at 500 changes", () => {
        // Unit test: directly test flush behavior by simulating many pending changes
        // This tests the truncation logic without relying on filesystem timing
        let capturedEvent = null;
        const watcher = new FileWatcher({
            projectId,
            projectPath: projectDir,
            onFsChanged: (event) => {
                capturedEvent = event;
            },
            debounceMs: 10,
        });
        // Access private pendingChanges to simulate many changes
        // We'll use a workaround: start watcher, add changes manually, then stop
        // Since stop() flushes pending changes, we can use that
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        const w = watcher;
        w.pendingChanges = Array.from({ length: 600 }, (_, i) => ({
            kind: "create",
            path: `file-${i}.txt`,
        }));
        // Flush via stop (which calls flush for pending changes)
        // But watcher isn't started, so stop won't close chokidar
        w.flush();
        expect(capturedEvent).not.toBeNull();
        expect(capturedEvent.payload.changes).toHaveLength(500);
        expect(capturedEvent.payload.changesTruncated).toBe(true);
        expect(capturedEvent.payload.seq).toBeGreaterThan(0);
    });
    it("should stop watching when stop is called", async () => {
        const { callback } = createEventCollector();
        const watcher = new FileWatcher({
            projectId,
            projectPath: projectDir,
            onFsChanged: callback,
            debounceMs: 50,
        });
        watcher.start();
        expect(watcher.isWatching).toBe(true);
        await watcher.stop();
        expect(watcher.isWatching).toBe(false);
    });
    it("should be idempotent on start", async () => {
        const { callback } = createEventCollector();
        const watcher = new FileWatcher({
            projectId,
            projectPath: projectDir,
            onFsChanged: callback,
            debounceMs: 50,
        });
        watcher.start();
        watcher.start(); // Should not create second watcher
        expect(watcher.isWatching).toBe(true);
        await watcher.stop();
    });
});
//# sourceMappingURL=file-watcher.test.js.map