import fg from 'fast-glob';
import ignore from 'ignore';
import path from 'node:path';
import { readFile } from 'node:fs/promises';
import { z } from 'zod';
import { OccAbortError } from './errors.js';
export function countWords(text) {
    if (!text)
        return 0;
    return text.split(/\s+/).filter(Boolean).length;
}
export function formatBytes(bytes) {
    if (bytes === 0)
        return '0 B';
    const units = ['B', 'KB', 'MB', 'GB'];
    const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
    const value = bytes / Math.pow(1024, i);
    return `${value < 10 ? value.toFixed(1) : Math.round(value)} ${units[i]}`;
}
export function formatNumber(n) {
    if (n == null)
        return '';
    return n.toLocaleString('en-US');
}
export function getExtension(filePath) {
    return path.extname(filePath).toLowerCase().replace('.', '');
}
export function normalizeRelativePath(filePath) {
    return filePath.split(path.sep).join('/');
}
export function buildExcludeDirGlobs(excludeDir) {
    return excludeDir.flatMap(dir => {
        const normalized = normalizeRelativePath(dir.replace(/^\/+|\/+$/g, ''));
        if (!normalized)
            return [];
        return [`**/${normalized}`, `**/${normalized}/**`];
    });
}
/**
 * Canonical directory names that are almost never wanted in scans or code
 * indexes: package installs, VCS internals, build output, and tool caches.
 * Exported so hosts can pass (or extend) one shared list instead of
 * maintaining their own.
 */
export const DEFAULT_EXCLUDE_DIRS = [
    'node_modules',
    '.git',
    'dist',
    'build',
    'out',
    'target',
    '.next',
    '__pycache__',
    '.venv',
    'vendor',
    'coverage',
];
export const EXTENSION_TO_TYPE = {
    docx: 'Word',
    pdf: 'PDF',
    xlsx: 'Excel',
    pptx: 'PowerPoint',
    odt: 'ODT',
    ods: 'ODS',
    odp: 'ODP',
};
export const OFFICE_EXTENSIONS = Object.keys(EXTENSION_TO_TYPE);
export const METRIC_FIELDS = ['words', 'pages', 'paragraphs', 'sheets', 'rows', 'cells', 'slides'];
export const MetricFieldSchema = z.enum(METRIC_FIELDS);
function applyIgnoreResult(current, result) {
    if (result.ignored)
        return true;
    if (result.unignored)
        return false;
    return current;
}
function isUnderBasePath(relativePath, basePath) {
    return basePath.length === 0 || relativePath === basePath || relativePath.startsWith(`${basePath}/`);
}
async function loadScopedGitignoreMatchers(rootDir, excludeDir) {
    const gitignorePaths = await fg(['.gitignore', '**/.gitignore'], {
        cwd: rootDir,
        absolute: true,
        onlyFiles: true,
        dot: true,
        followSymbolicLinks: false,
        ignore: buildExcludeDirGlobs(excludeDir),
        unique: true,
    });
    const scopedMatchers = await Promise.all(gitignorePaths.map(async (filePath) => {
        const content = await readFile(filePath, 'utf8');
        return {
            basePath: normalizeRelativePath(path.relative(rootDir, path.dirname(filePath))),
            matcher: ignore({ allowRelativePaths: true }).add(content.split(/\r?\n/)),
        };
    }));
    return scopedMatchers.sort((left, right) => {
        const leftDepth = left.basePath === '' ? 0 : left.basePath.split('/').length;
        const rightDepth = right.basePath === '' ? 0 : right.basePath.split('/').length;
        return leftDepth - rightDepth || compareCodeUnits(left.basePath, right.basePath);
    });
}
export async function createIgnoreMatcher(rootDir, options = {}) {
    const resolvedRoot = path.resolve(rootDir);
    const excludeDir = options.excludeDir ?? [];
    const gitignoreMatchers = options.noGitignore ? [] : await loadScopedGitignoreMatchers(resolvedRoot, excludeDir);
    const callerMatcher = options.ignorePatterns && options.ignorePatterns.length > 0
        ? ignore({ allowRelativePaths: true }).add(options.ignorePatterns)
        : null;
    const overlayMatcher = options.overlayIgnorePatterns && options.overlayIgnorePatterns.length > 0
        ? ignore({ allowRelativePaths: true }).add(options.overlayIgnorePatterns)
        : null;
    return {
        ignores(relativePath) {
            const normalizedPath = normalizeRelativePath(relativePath).replace(/^\.\/+/, '');
            let baseIgnored = false;
            for (const entry of gitignoreMatchers) {
                if (!isUnderBasePath(normalizedPath, entry.basePath))
                    continue;
                const scopedPath = entry.basePath.length === 0 ? normalizedPath : normalizedPath.slice(entry.basePath.length + 1);
                baseIgnored = applyIgnoreResult(baseIgnored, entry.matcher.test(scopedPath));
            }
            if (callerMatcher) {
                baseIgnored = applyIgnoreResult(baseIgnored, callerMatcher.test(normalizedPath));
            }
            let overlayIgnored = false;
            if (overlayMatcher) {
                overlayIgnored = applyIgnoreResult(false, overlayMatcher.test(normalizedPath));
            }
            return baseIgnored || overlayIgnored;
        },
    };
}
export function abortIfNeeded(signal) {
    if (signal?.aborted) {
        throw new OccAbortError();
    }
}
export function isCooperative(option) {
    return option === true || (typeof option === 'object' && option !== null);
}
export function createYielder(option, signal) {
    if (!isCooperative(option)) {
        return { tick: () => Promise.resolve() };
    }
    const opt = option === true ? {} : option;
    const yieldEveryMs = opt.yieldEveryMs ?? 8;
    const onYield = opt.onYield;
    const startedAt = Date.now();
    let lastYield = startedAt;
    return {
        async tick() {
            abortIfNeeded(signal);
            const now = Date.now();
            if (now - lastYield < yieldEveryMs)
                return;
            await new Promise(resolve => setImmediate(resolve));
            const after = Date.now();
            onYield?.({ elapsedMs: after - startedAt, sinceYieldMs: after - lastYield });
            lastYield = after;
            abortIfNeeded(signal);
        },
    };
}
/**
 * Map `items` through an async `fn` with at most `concurrency` calls in flight.
 *
 * Results are returned in input order (each worker writes into its item's slot).
 * Non-finite `concurrency` (NaN/Infinity) falls back to 1. The signal is checked
 * before each item is dispatched; an abort surfaces as OccAbortError. The first
 * rejection from `fn` propagates and stops further dispatch (in-flight items may
 * still complete) — callers that need per-item error capture should catch inside `fn`.
 */
export async function mapWithConcurrency(items, concurrency, fn, signal) {
    const results = new Array(items.length);
    if (items.length === 0)
        return results;
    const safeConcurrency = Number.isFinite(concurrency) ? Math.floor(concurrency) : 1;
    const workerCount = Math.max(1, Math.min(safeConcurrency, items.length));
    let cursor = 0;
    let failed = false;
    async function worker() {
        while (!failed && cursor < items.length) {
            abortIfNeeded(signal);
            const index = cursor++;
            try {
                results[index] = await fn(items[index], index);
            }
            catch (error) {
                failed = true;
                throw error;
            }
        }
    }
    await Promise.all(Array.from({ length: workerCount }, () => worker()));
    return results;
}
export function sanitizeForkExecArgv(execArgv = process.execArgv) {
    const sanitized = [];
    for (let i = 0; i < execArgv.length; i++) {
        const arg = execArgv[i];
        if (arg === '--input-type') {
            i++;
            continue;
        }
        if (arg.startsWith('--input-type='))
            continue;
        sanitized.push(arg);
    }
    return sanitized;
}
export function hasKey(field) {
    return `has${field[0].toUpperCase()}${field.slice(1)}`;
}
export async function writeStream(stream, text) {
    await new Promise((resolve, reject) => {
        stream.write(text, (error) => {
            if (error)
                reject(error);
            else
                resolve();
        });
    });
}
/**
 * Serialize a machine-readable payload with the CLI's one JSON policy.
 *
 * Compact JSON is the wire default. Callers may opt into indentation for
 * humans with the global `--pretty` flag. NDJSON records, cache files, and
 * canonical/digested JSON deliberately do not use this presentation helper.
 */
export function formatPayloadJson(payload, pretty = false) {
    return JSON.stringify(payload, null, pretty ? 2 : undefined) ?? 'null';
}
/**
 * A PDF's text, with the extractor's page furniture left out.
 *
 * `getText().text` interleaves a decorative `-- 1 of 2 --` separator between
 * pages. It reads well in a terminal and is wrong for every use OCC has: it
 * would add five words per page to a word count, and put text into the
 * document that is not in the document. The per-page bodies carry no such
 * decoration, so the joined form is rebuilt from those.
 */
export async function readPdfText(document) {
    const result = await document.getText();
    const pages = result.pages.map(page => ({ num: page.num, text: page.text }));
    return { pages, text: pages.map(page => page.text).join('\n\n'), total: result.total };
}
export async function withPdfDocument(buffer, use) {
    const { PDFParse, VerbosityLevel } = await import('pdf-parse').catch((error) => wrapDependencyLoadError('pdf-parse', error));
    // pdf.js takes ownership of the buffer it is given and detaches it, so the
    // bytes are copied: a caller that also reads the file for its own checks —
    // `isPdfEncrypted` scans the raw bytes — must not find them emptied.
    const document = new PDFParse({ data: new Uint8Array(buffer), verbosity: VerbosityLevel.ERRORS });
    try {
        return await use(document);
    }
    finally {
        await document.destroy();
    }
}
/**
 * Rethrow a lazy `import()` failure with the offending package named, so a
 * missing/broken heavy dependency surfaces as an actionable diagnostic instead
 * of a generic per-file parse failure.
 */
export function wrapDependencyLoadError(pkg, error) {
    const message = error instanceof Error ? error.message : String(error);
    throw new Error(`occ dependency '${pkg}' failed to load: ${message}`, { cause: error });
}
let officeparserModule;
async function loadOfficeparser() {
    officeparserModule ??= import('officeparser').catch((error) => wrapDependencyLoadError('officeparser', error));
    return officeparserModule;
}
export async function extractOfficeText(input) {
    const { OfficeParser } = await loadOfficeparser();
    const raw = await OfficeParser.parseOffice(input);
    if (typeof raw === 'string')
        return raw;
    if (raw && typeof raw.toText === 'function') {
        return z.string().parse(raw.toText());
    }
    throw new Error('officeparser.parseOffice returned unexpected shape');
}
/**
 * Locale-independent UTF-16 code-unit comparison. Deterministic wire output
 * must never use `localeCompare`, whose order follows the host locale.
 */
export function compareCodeUnits(a, b) {
    return a < b ? -1 : a > b ? 1 : 0;
}
//# sourceMappingURL=utils.js.map