import fs from 'node:fs';
import fsPromises from 'node:fs/promises';
import path from 'node:path';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { prisma } from '../utils/index.js';
import { WorkspaceKind, WorkspaceStatus } from '../types/index.js';
const execFileAsync = promisify(execFile);
const DEFAULT_DEBOUNCE_MS = 250;
const WATCHER_RETRY_MS = 1_000;
const IGNORED_PATH_SEGMENTS = new Set(['node_modules', '.agent-tower']);
async function pathExists(targetPath) {
    return fsPromises.access(targetPath).then(() => true).catch(() => false);
}
function normalizePath(targetPath) {
    return path.resolve(targetPath);
}
function isNotFoundLike(error) {
    return typeof error === 'object'
        && error !== null
        && 'code' in error
        && (error.code === 'ENOENT'
            || error.code === 'ENOTDIR');
}
/**
 * Watches ACTIVE worktree workspaces for filesystem and git metadata changes.
 *
 * The service intentionally listens to the filesystem rather than terminal
 * output, so changes from the built-in shell, external terminals, IDEs, and
 * scripts all flow through the same path.
 */
export class WorkspaceGitWatcherService {
    eventBus;
    watchers = new Map();
    debounceMs;
    retryMs;
    startupGeneration = 0;
    startupActive = false;
    serviceGeneration = 0;
    workspaceGenerations = new Map();
    loadActiveWorkspaces;
    constructor(eventBus, options = {}) {
        this.eventBus = eventBus;
        this.debounceMs = options.debounceMs ?? DEFAULT_DEBOUNCE_MS;
        this.retryMs = options.retryMs ?? WATCHER_RETRY_MS;
        this.loadActiveWorkspaces = options.loadActiveWorkspaces ?? (() => this.findActiveWorkspaces());
    }
    async start() {
        const generation = ++this.startupGeneration;
        this.startupActive = true;
        const workspaces = await this.loadActiveWorkspaces();
        for (const workspace of workspaces) {
            if (!this.isStartupCurrent(generation))
                return;
            await this.watchWorkspaceInternal(workspace, generation);
        }
    }
    stop() {
        this.startupActive = false;
        this.startupGeneration += 1;
        this.serviceGeneration += 1;
        for (const workspaceId of [...this.watchers.keys()]) {
            this.unwatchWorkspace(workspaceId);
        }
    }
    async refreshWorkspace(workspaceId) {
        const workspace = await prisma.workspace.findUnique({
            where: { id: workspaceId },
            include: { task: { select: { projectId: true, deletedAt: true } } },
        });
        if (!workspace
            || workspace.task.deletedAt
            || workspace.status !== WorkspaceStatus.ACTIVE
            || workspace.workspaceKind !== WorkspaceKind.WORKTREE
            || !workspace.worktreePath) {
            this.unwatchWorkspace(workspaceId);
            return;
        }
        await this.watchWorkspace({
            id: workspace.id,
            taskId: workspace.taskId,
            worktreePath: workspace.worktreePath,
            workingDir: workspace.workingDir,
            task: { projectId: workspace.task.projectId },
        });
    }
    async watchWorkspace(workspace) {
        await this.watchWorkspaceInternal(workspace);
    }
    async watchWorkspaceInternal(workspace, startupGeneration) {
        const registration = this.beginWorkspaceRegistration(workspace.id, startupGeneration);
        const canContinueRegistration = () => this.isRegistrationCurrent(workspace.id, registration);
        if (!canContinueRegistration())
            return;
        const workingDir = workspace.workingDir || workspace.worktreePath;
        if (!workingDir || !await pathExists(workingDir)) {
            this.unwatchWorkspace(workspace.id);
            return;
        }
        if (!canContinueRegistration())
            return;
        const normalizedWorkspace = {
            ...workspace,
            worktreePath: normalizePath(workspace.worktreePath),
            workingDir: normalizePath(workingDir),
        };
        const existing = this.watchers.get(workspace.id);
        if (existing && this.sameWorkspace(existing.workspace, normalizedWorkspace)) {
            return;
        }
        if (existing) {
            this.closeManagedWorkspace(workspace.id, existing);
        }
        const targets = await this.buildWatchTargets(normalizedWorkspace.workingDir);
        if (!canContinueRegistration())
            return;
        const managed = {
            workspace: normalizedWorkspace,
            targets,
            watchers: [],
            watchedPaths: new Set(),
            timers: new Set(),
            changeTimer: null,
            pendingReason: 'unknown',
            stopped: false,
        };
        this.watchers.set(normalizedWorkspace.id, managed);
        for (const target of managed.targets) {
            this.openWatcher(managed, target);
        }
    }
    unwatchWorkspace(workspaceId) {
        this.invalidateWorkspaceRegistration(workspaceId);
        const managed = this.watchers.get(workspaceId);
        if (!managed)
            return;
        this.closeManagedWorkspace(workspaceId, managed);
    }
    closeManagedWorkspace(workspaceId, managed) {
        managed.stopped = true;
        for (const watcher of managed.watchers) {
            watcher.close();
        }
        for (const timer of managed.timers) {
            clearTimeout(timer);
        }
        if (managed.changeTimer) {
            clearTimeout(managed.changeTimer);
        }
        this.watchers.delete(workspaceId);
    }
    getWatchedWorkspaceIds() {
        return [...this.watchers.keys()];
    }
    isStartupCurrent(generation) {
        return this.startupActive && this.startupGeneration === generation;
    }
    canContinueStartup(generation) {
        return generation === undefined || this.isStartupCurrent(generation);
    }
    beginWorkspaceRegistration(workspaceId, startupGeneration) {
        const workspaceGeneration = this.nextWorkspaceGeneration(workspaceId);
        const token = {
            serviceGeneration: this.serviceGeneration,
            workspaceGeneration,
        };
        if (startupGeneration !== undefined) {
            token.startupGeneration = startupGeneration;
        }
        return token;
    }
    invalidateWorkspaceRegistration(workspaceId) {
        this.nextWorkspaceGeneration(workspaceId);
    }
    nextWorkspaceGeneration(workspaceId) {
        const nextGeneration = (this.workspaceGenerations.get(workspaceId) ?? 0) + 1;
        this.workspaceGenerations.set(workspaceId, nextGeneration);
        return nextGeneration;
    }
    isRegistrationCurrent(workspaceId, token) {
        return this.serviceGeneration === token.serviceGeneration
            && this.workspaceGenerations.get(workspaceId) === token.workspaceGeneration
            && this.canContinueStartup(token.startupGeneration);
    }
    async findActiveWorkspaces() {
        return prisma.workspace.findMany({
            where: {
                status: WorkspaceStatus.ACTIVE,
                workspaceKind: WorkspaceKind.WORKTREE,
                worktreePath: { not: '' },
                task: { deletedAt: null },
            },
            include: { task: { select: { projectId: true } } },
        });
    }
    sameWorkspace(left, right) {
        return left.worktreePath === right.worktreePath
            && left.workingDir === right.workingDir
            && left.taskId === right.taskId
            && left.task.projectId === right.task.projectId;
    }
    async buildWatchTargets(workingDir) {
        const targets = [{ path: workingDir, recursive: true, kind: 'worktree' }];
        const gitDirs = await this.resolveGitDirs(workingDir);
        for (const gitDir of gitDirs) {
            if (await pathExists(gitDir)) {
                targets.push({ path: normalizePath(gitDir), recursive: false, kind: 'git-dir' });
            }
            const recursivePaths = ['refs', 'logs', 'rebase-merge', 'rebase-apply'];
            for (const child of recursivePaths) {
                const candidate = path.join(gitDir, child);
                if (await pathExists(candidate)) {
                    targets.push({ path: normalizePath(candidate), recursive: true, kind: 'git-dir' });
                }
            }
        }
        return this.dedupeTargets(targets);
    }
    dedupeTargets(targets) {
        const seen = new Set();
        const result = [];
        for (const target of targets) {
            const key = `${target.kind}:${target.path}:${target.recursive}`;
            if (seen.has(key))
                continue;
            seen.add(key);
            result.push(target);
        }
        return result;
    }
    async resolveGitDirs(workingDir) {
        const result = new Set();
        try {
            const [gitDirOutput, commonDirOutput] = await Promise.all([
                this.execGit(workingDir, ['rev-parse', '--git-dir']),
                this.execGit(workingDir, ['rev-parse', '--git-common-dir']),
            ]);
            for (const raw of [gitDirOutput, commonDirOutput]) {
                const resolved = this.resolveGitPath(workingDir, raw.trim());
                if (resolved)
                    result.add(resolved);
            }
        }
        catch {
            const dotGit = path.join(workingDir, '.git');
            if (await pathExists(dotGit)) {
                const stat = await fsPromises.stat(dotGit).catch(() => null);
                if (stat?.isDirectory()) {
                    result.add(dotGit);
                }
            }
        }
        return [...result];
    }
    resolveGitPath(workingDir, gitPath) {
        if (!gitPath)
            return null;
        return normalizePath(path.isAbsolute(gitPath) ? gitPath : path.join(workingDir, gitPath));
    }
    openWatcher(managed, target) {
        if (managed.stopped)
            return;
        if (managed.watchedPaths.has(target.path))
            return;
        try {
            const watcher = fs.watch(target.path, { recursive: target.recursive }, (_eventType, filename) => {
                if (filename && this.shouldIgnorePath(filename.toString()))
                    return;
                this.scheduleChange(managed, target.kind === 'git-dir' ? 'git-dir' : 'worktree');
            });
            watcher.on('error', (error) => {
                if (managed.stopped)
                    return;
                if (isNotFoundLike(error)) {
                    this.scheduleRetry(managed, target);
                    return;
                }
                console.warn(`[WorkspaceGitWatcher] watcher error for ${managed.workspace.id} at ${target.path}:`, error instanceof Error ? error.message : error);
            });
            managed.watchers.push(watcher);
            managed.watchedPaths.add(target.path);
        }
        catch (error) {
            if (target.recursive && this.isRecursiveWatchUnsupported(error)) {
                this.openDirectoryTreeWatchers(managed, target.path, target.kind).catch((err) => {
                    console.warn(`[WorkspaceGitWatcher] failed to open directory tree watchers for ${target.path}:`, err instanceof Error ? err.message : err);
                });
                return;
            }
            if (isNotFoundLike(error)) {
                this.scheduleRetry(managed, target);
                return;
            }
            console.warn(`[WorkspaceGitWatcher] failed to watch ${target.path} for ${managed.workspace.id}:`, error instanceof Error ? error.message : error);
        }
    }
    isRecursiveWatchUnsupported(error) {
        return typeof error === 'object'
            && error !== null
            && 'code' in error
            && (error.code === 'ERR_FEATURE_UNAVAILABLE_ON_PLATFORM'
                || error.code === 'ERR_INVALID_ARG_VALUE');
    }
    async openDirectoryTreeWatchers(managed, rootPath, kind) {
        if (managed.stopped || !await pathExists(rootPath))
            return;
        const entries = await fsPromises.readdir(rootPath, { withFileTypes: true }).catch(() => []);
        if (managed.stopped)
            return;
        this.openNonRecursiveWatcher(managed, rootPath, kind);
        for (const entry of entries) {
            if (!entry.isDirectory())
                continue;
            if (this.shouldIgnorePath(path.join(rootPath, entry.name)))
                continue;
            await this.openDirectoryTreeWatchers(managed, path.join(rootPath, entry.name), kind);
        }
    }
    openNonRecursiveWatcher(managed, targetPath, kind) {
        if (managed.stopped)
            return;
        const normalizedTarget = normalizePath(targetPath);
        if (managed.watchedPaths.has(normalizedTarget))
            return;
        try {
            const watcher = fs.watch(normalizedTarget, (eventType, filename) => {
                const childPath = filename ? path.join(normalizedTarget, filename.toString()) : normalizedTarget;
                if (this.shouldIgnorePath(childPath))
                    return;
                this.scheduleChange(managed, kind === 'git-dir' ? 'git-dir' : 'worktree');
                if (eventType === 'rename') {
                    fsPromises.stat(childPath)
                        .then((stat) => {
                        if (stat.isDirectory()) {
                            return this.openDirectoryTreeWatchers(managed, childPath, kind);
                        }
                    })
                        .catch(() => undefined);
                }
            });
            watcher.on('error', (error) => {
                if (managed.stopped)
                    return;
                if (isNotFoundLike(error))
                    return;
                console.warn(`[WorkspaceGitWatcher] watcher error for ${managed.workspace.id} at ${normalizedTarget}:`, error instanceof Error ? error.message : error);
            });
            managed.watchers.push(watcher);
            managed.watchedPaths.add(normalizedTarget);
        }
        catch (error) {
            if (isNotFoundLike(error))
                return;
            console.warn(`[WorkspaceGitWatcher] failed to watch ${normalizedTarget} for ${managed.workspace.id}:`, error instanceof Error ? error.message : error);
        }
    }
    scheduleRetry(managed, target) {
        if (managed.stopped)
            return;
        const timer = setTimeout(() => {
            managed.timers.delete(timer);
            this.openWatcher(managed, target);
        }, this.retryMs);
        managed.timers.add(timer);
        timer.unref?.();
    }
    shouldIgnorePath(filename) {
        return filename
            .replace(/\\/g, '/')
            .split('/')
            .some((segment) => IGNORED_PATH_SEGMENTS.has(segment));
    }
    scheduleChange(managed, reason) {
        if (managed.stopped)
            return;
        managed.pendingReason = reason;
        this.scheduleMaybeChanged(managed);
    }
    scheduleMaybeChanged(managed) {
        if (managed.stopped)
            return;
        if (managed.changeTimer) {
            clearTimeout(managed.changeTimer);
        }
        const timer = setTimeout(() => {
            managed.changeTimer = null;
            this.emitMaybeChanged(managed, managed.pendingReason);
        }, this.debounceMs);
        managed.changeTimer = timer;
        timer.unref?.();
    }
    emitMaybeChanged(managed, reason) {
        if (managed.stopped)
            return;
        this.eventBus.emit('workspace:git_changed', {
            workspaceId: managed.workspace.id,
            taskId: managed.workspace.taskId,
            projectId: managed.workspace.task.projectId,
            workingDir: managed.workspace.workingDir,
            reason,
        });
    }
    async execGit(workingDir, args) {
        const { stdout } = await execFileAsync('git', args, {
            cwd: workingDir,
            timeout: 10_000,
            maxBuffer: 10 * 1024 * 1024,
            encoding: 'utf-8',
        });
        return stdout;
    }
}
//# sourceMappingURL=workspace-git-watcher.service.js.map