"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
    return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.EntryHandler = void 0;
const harper_logger_ts_1 = require("../utility/logging/harper_logger.js");
const node_events_1 = require("node:events");
const Component_ts_1 = require("./Component.js");
const chokidar_1 = __importDefault(require("chokidar"));
const node_path_1 = require("node:path");
const promises_1 = require("node:fs/promises");
const deriveURLPath_ts_1 = require("./deriveURLPath.js");
const micromatch_1 = require("micromatch");
const watcherFallback_ts_1 = require("../utility/watcherFallback.js");
class EntryHandler extends node_events_1.EventEmitter {
    #component;
    #watcher;
    #logger;
    #pendingFileReads;
    #isInitialScanComplete;
    // When true, #watch() short-circuits without creating a chokidar watcher.
    // pause() sets it, resume() clears it. Lets a deploy quiesce the watcher
    // without losing the EntryHandler instance (and therefore listener
    // attachments registered by plugins via scope.handleEntry(handler)).
    #paused = false;
    // Tracks the in-flight close() promise from pause() so resume() can await
    // the old watcher's inotify handles releasing before installing a fresh
    // chokidar instance — otherwise a rapid pause→resume can overlap teardown
    // and setup, which under inotify pressure can produce spurious EMFILE.
    #pausedClose;
    #usingPolling = false;
    #closed = false;
    #openCount = 0;
    ready;
    constructor(name, directory, config, logger) {
        super();
        this.#component = new Component_ts_1.Component(name, directory, castConfig(config));
        this.#logger = logger || (0, harper_logger_ts_1.loggerWithTag)(name);
        this.#pendingFileReads = new Set();
        this.#isInitialScanComplete = false;
        this.ready = (0, node_events_1.once)(this, 'ready');
        this.#watch();
    }
    get name() {
        return this.#component.name;
    }
    get directory() {
        return this.#component.directory;
    }
    #handleAll(...[event, path, stats]) {
        if (path === '')
            path = '/';
        if (!(0, micromatch_1.isMatch)(path, this.#component.globOptions.source, { ignore: this.#component.globOptions.ignore }))
            return;
        const absolutePath = (0, node_path_1.join)(this.directory, path);
        switch (event) {
            case 'add':
            case 'change': {
                const urlPath = (0, deriveURLPath_ts_1.deriveURLPath)(this.#component, path, 'file');
                const fileReadPromise = (0, promises_1.readFile)(absolutePath)
                    .then((contents) => {
                    const entry = {
                        eventType: event,
                        entryType: 'file',
                        contents,
                        stats,
                        absolutePath,
                        urlPath,
                    };
                    this.emit('all', entry);
                    this.emit(event, entry);
                })
                    .finally(() => {
                    this.#pendingFileReads.delete(fileReadPromise);
                    this.#checkIfAllComplete();
                });
                this.#pendingFileReads.add(fileReadPromise);
                break;
            }
            case 'unlink': {
                const urlPath = (0, deriveURLPath_ts_1.deriveURLPath)(this.#component, path, 'file');
                const entry = {
                    eventType: event,
                    entryType: 'file',
                    stats,
                    absolutePath,
                    urlPath,
                };
                this.emit('all', entry);
                this.emit(event, entry);
                break;
            }
            case 'addDir':
            case 'unlinkDir': {
                const urlPath = (0, deriveURLPath_ts_1.deriveURLPath)(this.#component, path, 'directory');
                const entry = {
                    eventType: event,
                    entryType: 'directory',
                    stats,
                    absolutePath,
                    urlPath,
                };
                this.emit('all', entry);
                this.emit(event, entry);
                break;
            }
        }
    }
    #handleError(error) {
        if ((0, watcherFallback_ts_1.isWatcherExhaustionError)(error)) {
            // Swallow every exhaustion error — chokidar can emit several before the
            // failed native watcher closes, and we don't want a flurry of ENOSPC to
            // surface to consumers in the middle of recovery.
            if (!this.#usingPolling) {
                (0, watcherFallback_ts_1.warnWatcherFallback)(this.#component.directory);
                this.#usingPolling = true;
                // Reopen with polling. #watch() itself guards against reopen-after-close.
                // The .catch is required because #watch() internally awaits the failed
                // watcher's close(), which can reject under the same FD/inotify pressure
                // that triggered this path; without it Node would treat that as an
                // unhandled rejection (matches the .catch pattern used in
                // OptionsWatcher / RootConfigWatcher).
                this.#watch().catch(() => {
                    // Teardown errors on an already-failed watcher are not actionable.
                });
            }
            return;
        }
        this.emit('error', error);
    }
    #handleReady() {
        this.#isInitialScanComplete = true;
        if (this.#pendingFileReads.size > 0) {
            this.#logger.debug?.(`Initial scan complete, still waiting for ${this.#pendingFileReads.size} pending file reads`);
        }
        this.#checkIfAllComplete();
    }
    #checkIfAllComplete() {
        // Only emit 'ready' once the initial scan is complete AND all file reads are done
        if (this.#isInitialScanComplete && this.#pendingFileReads.size === 0) {
            this.emit('ready');
        }
    }
    async #watch() {
        // If pause() retained an in-flight close, wait for it to release inotify
        // handles before we install a new watcher. Otherwise a fast pause→resume
        // can overlap teardown and setup under inotify pressure.
        if (this.#pausedClose) {
            await this.#pausedClose;
            this.#pausedClose = undefined;
        }
        await this.#watcher?.close();
        this.#watcher = undefined;
        // If close() landed while a previous close()/recreate was awaiting, don't
        // install a fresh watcher — it would outlive the EntryHandler.
        if (this.#closed)
            return this.ready;
        // pause() may have landed in the gap before our async close resolved.
        // If so, do not install a replacement watcher — resume() will.
        if (this.#paused)
            return this.ready;
        // When a fresh watcher is installed (after pause+resume, or update), the
        // initial scan emits add events anew, so reset the readiness latch so
        // `ready` resolves after the new scan completes.
        this.#isInitialScanComplete = false;
        const allowedBases = this.#component.patternBases.map((base) => (0, node_path_1.join)(this.#component.directory, base));
        this.#openCount++;
        this.#watcher = chokidar_1.default
            .watch(this.#component.commonPatternBase, {
            cwd: this.#component.directory,
            persistent: false,
            followSymlinks: false,
            ...(this.#usingPolling ? watcherFallback_ts_1.DIRECTORY_POLLING_FALLBACK_OPTIONS : {}),
            ignored: (path) => {
                const normalizedPath = path.replace(/\\/g, '/');
                const normalizedBases = allowedBases.map((base) => base.replace(/\\/g, '/'));
                const normalizedDirectory = this.#component.directory.replace(/\\/g, '/');
                // Determine the path relative to the component directory. Leading '/' is preserved
                // (or empty when the path *is* the component directory) so the regex anchors below
                // can use `(?:^|/)` to match the first segment without false positives on names
                // that merely contain the same substring (e.g. `mynode_modules`, `notgit`).
                const relativePath = normalizedPath.startsWith(normalizedDirectory)
                    ? normalizedPath.slice(normalizedDirectory.length)
                    : normalizedPath;
                // Skip node_modules at any depth. This allows plugins loaded from node_modules
                // to still watch their own component files while ignoring their dependencies.
                if (/(?:^|\/)node_modules(?:\/|$)/.test(relativePath))
                    return true;
                // Skip transient package manager and VCS artifacts. Without these, an in-place
                // `npm install` during a component deploy writes log files and atomic-rename
                // temp directories that fire change events and drive an auto-reload restart
                // storm — see harper#488.
                if (/(?:^|\/)\.git(?:\/|$)/.test(relativePath))
                    return true;
                if (/(?:^|\/)\.tmp-/.test(relativePath))
                    return true;
                if (/(?:^|\/)(?:npm-debug|yarn-error|yarn-debug|pnpm-debug)\.log(?:\/|$)/.test(relativePath))
                    return true;
                return (normalizedPath !== normalizedDirectory && normalizedBases.every((base) => !normalizedPath.startsWith(base)));
            },
        })
            .on('all', this.#handleAll.bind(this))
            .on('error', this.#handleError.bind(this))
            .on('ready', this.#handleReady.bind(this));
        return this.ready;
    }
    // Test-only: simulate the underlying chokidar watcher emitting an error.
    // Exposed so the polling-fallback path can be exercised without triggering a
    // real ENOSPC/EMFILE on the host.
    _simulateWatcherErrorForTests(error) {
        this.#handleError(error);
    }
    // Test-only: whether the watcher has fallen back to polling.
    get _usingPollingForTests() {
        return this.#usingPolling;
    }
    // Test-only: number of times the underlying watcher has been (re)opened.
    // Used to assert that a close()-during-fallback race didn't install a
    // replacement watcher.
    get _openCountForTests() {
        return this.#openCount;
    }
    close() {
        this.#closed = true;
        const pendingReads = [...this.#pendingFileReads];
        const watcherClose = this.#watcher ? Promise.resolve(this.#watcher.close()).catch(() => { }) : Promise.resolve();
        this.#watcher = undefined;
        // If paused, there may be an in-flight close from pause() that hasn't settled yet.
        // Include it so close() doesn't resolve while inotify handles are still releasing.
        const pausedClose = this.#pausedClose ?? Promise.resolve();
        this.#pausedClose = undefined;
        this.emit('close');
        this.removeAllListeners();
        return Promise.allSettled([watcherClose, pausedClose, ...pendingReads]).then(() => this);
    }
    /**
     * Quiesce the watcher without tearing down the EntryHandler. Closes the
     * underlying chokidar watcher (releasing inotify handles for the watched
     * tree) but preserves all listeners attached to this instance, so plugins
     * that registered `scope.handleEntry(handler)` keep their handler wired up
     * across the pause.
     *
     * Idempotent. Awaiting `ready` while paused will not resolve until resume().
     */
    pause() {
        this.#paused = true;
        // Reset `ready` to a fresh pending promise so the documented "awaiting
        // ready while paused will not resolve until resume()" contract holds even
        // when the watcher had already become ready before pause(). The next
        // 'ready' emit will come from the chokidar instance installed by resume().
        this.ready = (0, node_events_1.once)(this, 'ready');
        if (this.#watcher) {
            // Retain the close promise so resume()→#watch() can await full
            // teardown before opening a new watcher.
            this.#pausedClose = Promise.resolve(this.#watcher.close()).catch(() => {
                // Teardown errors aren't actionable; swallow so resume can proceed.
            });
            this.#watcher = undefined;
        }
    }
    /**
     * Reinstate the watcher previously stopped by pause(). The fresh chokidar
     * instance does an initial scan and emits add events for every file
     * currently matching the configured globs — by design, since the typical
     * caller (Scope, on deploy:end) wants plugins to see the post-deploy tree
     * as if loading cold.
     *
     * No-op if not currently paused.
     */
    resume() {
        if (!this.#paused)
            return this.ready;
        this.#paused = false;
        // `this.ready` was already reset to a pending promise in pause(); just
        // trigger the watcher recreation and let its 'ready' emit resolve it.
        return this.#watch();
    }
    update(config) {
        this.#component = new Component_ts_1.Component(this.name, this.directory, castConfig(config));
        return this.#watch();
    }
}
exports.EntryHandler = EntryHandler;
function castConfig(config) {
    return typeof config === 'string' || Array.isArray(config) || !('files' in config) ? { files: config } : config;
}
//# sourceMappingURL=EntryHandler.js.map