import path from 'node:path';
import chokidar from 'chokidar';
import { projectDir, resolveProjectDir } from './projects.js';
/**
 * Refcounted per-project file watcher registry.
 *
 * Subscribers receive `{type, path, kind}` events when files inside the project
 * change on disk. The first subscribe lazy-creates a chokidar watcher; the last
 * unsubscribe closes it, so we never hold descriptors for projects no UI is
 * looking at.
 */
// Names we never want to surface as project file changes. Tested per-segment
// against the path *relative to the watch root* so that ancestor directories
// (e.g. the daemon's own `.od/` runtime dir, which contains every project) do
// not accidentally match and silence every event in the tree.
const IGNORE_NAMES = new Set([
    '.git',
    'node_modules',
    '.od',
    'debug',
    '.DS_Store',
    // Python virtual environments and caches — can contain tens of thousands of
    // files, exhausting the process fd table and breaking child-process spawning.
    // These names are safe to match at any path depth: a directory named `.venv`
    // or `__pycache__` is never legitimate authored source in a project tree.
    '.venv',
    'venv',
    '__pycache__',
    '.mypy_cache',
    '.pytest_cache',
    '.tox',
    '.ruff_cache',
]);
export function makeIgnored(rootDir) {
    return (absPath) => {
        const rel = path.relative(rootDir, absPath);
        if (!rel || rel === '' || rel.startsWith('..'))
            return false; // never ignore root itself
        return rel.split(/[\\/]/).some((seg) => IGNORE_NAMES.has(seg));
    };
}
export const DEFAULT_AWAIT_WRITE_FINISH = {
    stabilityThreshold: 200,
    pollInterval: 50,
};
const registry = new Map();
function makeEntry(dir, opts) {
    const watcher = chokidar.watch(dir, {
        ignored: opts.ignored,
        ignoreInitial: true,
        awaitWriteFinish: opts.awaitWriteFinish,
        persistent: true,
        // Don't follow symlinks out of the project root. Even though the relative-
        // path ignore predicate keeps emitted events project-scoped, an unhandled
        // symlink would still cost descriptors and surface external FS activity.
        followSymlinks: false,
    });
    // chokidar's FSWatcher is an EventEmitter. Without an `error` listener,
    // transient FS faults (ENOSPC, EPERM, EMFILE on saturated inotify watches)
    // would surface as unhandled exceptions and could crash the daemon — taking
    // every other route down with it. Log and keep the watcher alive; refcount
    // cleanup is unaffected.
    watcher.on('error', (err) => {
        if (process.env.NODE_ENV === 'development') {
            console.warn('[project-watchers] chokidar error in', dir, err);
        }
    });
    let resolveReady;
    const ready = new Promise((resolve) => { resolveReady = resolve; });
    watcher.once('ready', () => resolveReady());
    const entry = {
        dir,
        watcher,
        ready,
        subscribers: new Set(),
        closing: null,
    };
    const broadcast = (kind) => (absPath) => {
        const rel = path.relative(dir, absPath);
        if (!rel || rel.startsWith('..'))
            return;
        const evt = { type: 'file-changed', path: rel.split(path.sep).join('/'), kind };
        for (const cb of entry.subscribers) {
            try {
                cb(evt);
            }
            catch (err) {
                // A buggy subscriber must not poison siblings. Log in dev so the bug
                // doesn't go silent during local testing.
                if (process.env.NODE_ENV === 'development') {
                    console.warn('[project-watchers] subscriber threw on', evt.path, err);
                }
            }
        }
    };
    watcher.on('add', broadcast('add'));
    watcher.on('change', broadcast('change'));
    watcher.on('unlink', broadcast('unlink'));
    return entry;
}
/**
 * Subscribe to file-change events for a project.
 *
 * @param {string} projectsRoot Absolute path to the projects parent directory.
 * @param {string} projectId Project id (validated by projectDir()).
 * @param {(evt: {type: 'file-changed', path: string, kind: 'add'|'change'|'unlink'}) => void} onEvent
 * @param {{ ignored?: string[], awaitWriteFinish?: object, _watcherFactory?: typeof makeEntry }} [opts]
 * @returns {{ unsubscribe: () => Promise<void>, ready: Promise<void> }}
 *   `unsubscribe` releases the subscriber and closes the watcher if it was the
 *   last; `ready` resolves once chokidar has finished its initial scan.
 */
export function subscribe(projectsRoot, projectId, onEvent, opts = {}) {
    // Resolve to the project's actual root: for folder-imported projects
    // (metadata.baseDir set) we watch the user's folder so the live-reload
    // SSE stream actually fires when their files change. The registry is
    // keyed by the resolved directory, not the project id, so two
    // projects pointing at the same folder share one watcher.
    const dir = opts.metadata
        ? resolveProjectDir(projectsRoot, projectId, opts.metadata)
        : projectDir(projectsRoot, projectId);
    const key = dir;
    let entry = registry.get(key);
    if (!entry) {
        const factory = opts._watcherFactory || makeEntry;
        entry = factory(dir, {
            ignored: opts.ignored || makeIgnored(dir),
            awaitWriteFinish: opts.awaitWriteFinish || DEFAULT_AWAIT_WRITE_FINISH,
        });
        registry.set(key, entry);
    }
    entry.subscribers.add(onEvent);
    let unsubscribed = false;
    const unsubscribe = async () => {
        if (unsubscribed)
            return;
        unsubscribed = true;
        entry.subscribers.delete(onEvent);
        if (entry.subscribers.size === 0) {
            registry.delete(key);
            if (!entry.closing)
                entry.closing = entry.watcher.close();
            await entry.closing;
        }
    };
    return { unsubscribe, ready: entry.ready || Promise.resolve() };
}
/** Test-only: drop all watchers. */
export async function _resetForTests() {
    const entries = Array.from(registry.values());
    registry.clear();
    await Promise.allSettled(entries.map((e) => e.watcher.close()));
}
/** Test-only: number of active watchers. */
export function _activeWatcherCount() {
    return registry.size;
}
/** Test-only: return the chokidar FSWatcher for a given project's directory. */
export function _internalWatcherForTests(projectsRoot, projectId) {
    const dir = projectDir(projectsRoot, projectId);
    return registry.get(dir)?.watcher;
}
//# sourceMappingURL=project-watchers.js.map