"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
    return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.docsFilesService = exports.FileNotFoundError = exports.PathTraversalError = void 0;
const promises_1 = require("fs/promises");
const path_1 = require("path");
const crypto_1 = __importDefault(require("crypto"));
const env_1 = require("../../config/env");
const redis_1 = require("../../config/redis");
const logger_1 = require("../../utils/logger");
const metrics_1 = require("../../utils/metrics");
const DOCS_ROOT = (0, path_1.resolve)(env_1.env.MKDOCS_DOCS_PATH);
// Redis cache configuration
const CACHE_KEY_PREFIX = 'DOCS_CACHE:';
const TREE_CACHE_KEY = `${CACHE_KEY_PREFIX}tree`;
const TREE_CACHE_TTL = 30; // 30 seconds — short so external changes show quickly
const FILE_CONTENT_CACHE_TTL = 60 * 60; // 1 hour for file content
function hashFilePath(path) {
    return crypto_1.default.createHash('sha256').update(path).digest('hex').substring(0, 16);
}
/**
 * Resolve and validate a relative path within MKDOCS_DOCS_PATH.
 * Throws if the resolved path escapes the docs root.
 */
function safeResolve(relativePath) {
    const normalized = (0, path_1.normalize)(relativePath).replace(/^(\.\.(\/|\\|$))+/, '');
    const resolved = (0, path_1.resolve)(DOCS_ROOT, normalized);
    // Use DOCS_ROOT + sep to prevent prefix attacks (e.g., /mkdocs/docs-evil matching /mkdocs/docs)
    if (resolved !== DOCS_ROOT && !resolved.startsWith(DOCS_ROOT + '/')) {
        throw new PathTraversalError();
    }
    return resolved;
}
class PathTraversalError extends Error {
    constructor() {
        super('Path traversal not allowed');
        this.name = 'PathTraversalError';
    }
}
exports.PathTraversalError = PathTraversalError;
class FileNotFoundError extends Error {
    constructor(filePath) {
        super(`File not found: ${filePath}`);
        this.name = 'FileNotFoundError';
    }
}
exports.FileNotFoundError = FileNotFoundError;
/**
 * Recursively list the file tree under MKDOCS_DOCS_PATH.
 * Cached in Redis with 1-hour TTL for root calls.
 */
async function listTree(dir = DOCS_ROOT, relBase = '') {
    // Try cache for root call only
    if (dir === DOCS_ROOT && !relBase) {
        try {
            const cached = await redis_1.redis.get(TREE_CACHE_KEY);
            if (cached) {
                metrics_1.cm_docs_cache_hits.inc({ type: 'tree' });
                return JSON.parse(cached);
            }
            metrics_1.cm_docs_cache_misses.inc({ type: 'tree' });
        }
        catch (err) {
            logger_1.logger.warn('Failed to get cached docs tree:', err);
            metrics_1.cm_docs_cache_misses.inc({ type: 'tree' });
        }
    }
    const entries = await (0, promises_1.readdir)(dir, { withFileTypes: true });
    const sorted = entries
        .filter(e => !e.name.startsWith('.'))
        .sort((a, b) => {
        if (a.isDirectory() && !b.isDirectory())
            return -1;
        if (!a.isDirectory() && b.isDirectory())
            return 1;
        return a.name.localeCompare(b.name);
    });
    // Parallel I/O instead of sequential for better performance
    const nodes = await Promise.all(sorted.map(async (entry) => {
        const relPath = relBase ? `${relBase}/${entry.name}` : entry.name;
        if (entry.isDirectory()) {
            const children = await listTree((0, path_1.join)(dir, entry.name), relPath);
            return { name: entry.name, path: relPath, isDirectory: true, children };
        }
        else {
            return { name: entry.name, path: relPath, isDirectory: false };
        }
    }));
    // Cache root result
    if (dir === DOCS_ROOT && !relBase) {
        try {
            await redis_1.redis.setex(TREE_CACHE_KEY, TREE_CACHE_TTL, JSON.stringify(nodes));
        }
        catch (err) {
            logger_1.logger.warn('Failed to cache docs tree:', err);
        }
    }
    return nodes;
}
async function readFileContent(relativePath) {
    const cacheKey = `${CACHE_KEY_PREFIX}file:${hashFilePath(relativePath)}`;
    // Try cache first
    try {
        const cached = await redis_1.redis.get(cacheKey);
        if (cached) {
            metrics_1.cm_docs_cache_hits.inc({ type: 'file' });
            return cached;
        }
        metrics_1.cm_docs_cache_misses.inc({ type: 'file' });
    }
    catch (err) {
        logger_1.logger.warn('Failed to get cached file content:', err);
        metrics_1.cm_docs_cache_misses.inc({ type: 'file' });
    }
    // Read from disk
    const fullPath = safeResolve(relativePath);
    try {
        const content = await (0, promises_1.readFile)(fullPath, 'utf-8');
        // Cache the result
        try {
            await redis_1.redis.setex(cacheKey, FILE_CONTENT_CACHE_TTL, content);
        }
        catch (err) {
            logger_1.logger.warn('Failed to cache file content:', err);
        }
        return content;
    }
    catch (err) {
        if (err.code === 'ENOENT') {
            throw new FileNotFoundError(relativePath);
        }
        throw err;
    }
}
async function writeFileContent(relativePath, content) {
    const fullPath = safeResolve(relativePath);
    await (0, promises_1.mkdir)((0, path_1.dirname)(fullPath), { recursive: true });
    await (0, promises_1.writeFile)(fullPath, content, 'utf-8');
    // Invalidate file cache (content changed, structure unchanged)
    const cacheKey = `${CACHE_KEY_PREFIX}file:${hashFilePath(relativePath)}`;
    try {
        await redis_1.redis.del(cacheKey);
    }
    catch (err) {
        logger_1.logger.warn('Failed to invalidate file cache:', err);
    }
}
async function createFile(relativePath, content, isDirectory) {
    const fullPath = safeResolve(relativePath);
    try {
        await (0, promises_1.stat)(fullPath);
        throw new Error(`Already exists: ${relativePath}`);
    }
    catch (err) {
        if (err.code !== 'ENOENT') {
            if (err.message?.startsWith('Already exists'))
                throw err;
            throw err;
        }
    }
    if (isDirectory) {
        await (0, promises_1.mkdir)(fullPath, { recursive: true });
    }
    else {
        await (0, promises_1.mkdir)((0, path_1.dirname)(fullPath), { recursive: true });
        await (0, promises_1.writeFile)(fullPath, content || '', 'utf-8');
    }
    // Invalidate tree cache (structure changed)
    try {
        await redis_1.redis.del(TREE_CACHE_KEY);
    }
    catch (err) {
        logger_1.logger.warn('Failed to invalidate tree cache:', err);
    }
}
async function deleteFile(relativePath) {
    const fullPath = safeResolve(relativePath);
    try {
        const info = await (0, promises_1.stat)(fullPath);
        if (info.isDirectory()) {
            const entries = await (0, promises_1.readdir)(fullPath);
            if (entries.length > 0) {
                throw new Error('Directory is not empty');
            }
            await (0, promises_1.rm)(fullPath, { recursive: false });
        }
        else {
            await (0, promises_1.rm)(fullPath);
        }
    }
    catch (err) {
        if (err.code === 'ENOENT') {
            throw new FileNotFoundError(relativePath);
        }
        throw err;
    }
    // Invalidate both tree and file cache
    const fileCacheKey = `${CACHE_KEY_PREFIX}file:${hashFilePath(relativePath)}`;
    try {
        await Promise.all([
            redis_1.redis.del(TREE_CACHE_KEY),
            redis_1.redis.del(fileCacheKey),
        ]);
    }
    catch (err) {
        logger_1.logger.warn('Failed to invalidate caches on delete:', err);
    }
}
async function renameFile(fromPath, toPath) {
    const fullFrom = safeResolve(fromPath);
    const fullTo = safeResolve(toPath);
    try {
        await (0, promises_1.stat)(fullFrom);
    }
    catch {
        throw new FileNotFoundError(fromPath);
    }
    await (0, promises_1.mkdir)((0, path_1.dirname)(fullTo), { recursive: true });
    await (0, promises_1.rename)(fullFrom, fullTo);
    // Invalidate tree and both file paths
    const fromCacheKey = `${CACHE_KEY_PREFIX}file:${hashFilePath(fromPath)}`;
    const toCacheKey = `${CACHE_KEY_PREFIX}file:${hashFilePath(toPath)}`;
    try {
        await Promise.all([
            redis_1.redis.del(TREE_CACHE_KEY),
            redis_1.redis.del(fromCacheKey),
            redis_1.redis.del(toCacheKey),
        ]);
    }
    catch (err) {
        logger_1.logger.warn('Failed to invalidate caches on rename:', err);
    }
}
function isEditableFile(relativePath) {
    const ext = (0, path_1.extname)(relativePath).toLowerCase();
    return ['.md', '.txt', '.yml', '.yaml', '.json', '.css', '.html', '.js'].includes(ext);
}
const ALLOWED_UPLOAD_EXTENSIONS = new Set([
    '.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', '.ico',
    '.pdf', '.zip',
]);
async function uploadFile(relativePath, sourcePath) {
    const ext = (0, path_1.extname)(relativePath).toLowerCase();
    if (!ALLOWED_UPLOAD_EXTENSIONS.has(ext)) {
        throw new Error(`File type not allowed: ${ext}`);
    }
    const fullPath = safeResolve(relativePath);
    await (0, promises_1.mkdir)((0, path_1.dirname)(fullPath), { recursive: true });
    await (0, promises_1.copyFile)(sourcePath, fullPath);
    // Invalidate tree cache (structure changed)
    try {
        await redis_1.redis.del(TREE_CACHE_KEY);
    }
    catch (err) {
        logger_1.logger.warn('Failed to invalidate tree cache after upload:', err);
    }
}
async function invalidateTreeCache() {
    try {
        await redis_1.redis.del(TREE_CACHE_KEY);
    }
    catch (err) {
        logger_1.logger.warn('Failed to invalidate tree cache:', err);
    }
}
/**
 * Flatten a FileNode tree into file-only entries, then filter by
 * case-insensitive query match on name or path.  Name matches score
 * higher so they sort first.
 */
async function searchFiles(query, limit = 5) {
    const tree = await listTree();
    const q = query.toLowerCase();
    const matches = [];
    function walk(nodes) {
        for (const node of nodes) {
            if (node.isDirectory) {
                if (node.children)
                    walk(node.children);
            }
            else {
                const nameLower = node.name.toLowerCase();
                const pathLower = node.path.toLowerCase();
                if (nameLower.includes(q)) {
                    matches.push({ name: node.name, path: node.path, score: 2 });
                }
                else if (pathLower.includes(q)) {
                    matches.push({ name: node.name, path: node.path, score: 1 });
                }
            }
        }
    }
    walk(tree);
    matches.sort((a, b) => b.score - a.score);
    return matches.slice(0, limit).map(({ name, path }) => ({ name, path }));
}
exports.docsFilesService = {
    listTree,
    readFileContent,
    writeFileContent,
    createFile,
    deleteFile,
    renameFile,
    uploadFile,
    safeResolve,
    isEditableFile,
    invalidateTreeCache,
    searchFiles,
};
//# sourceMappingURL=docs-files.service.js.map