/*
 * Copyright 2026, Jaime Terrats.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, rmdirSync, statSync, unlinkSync, writeFileSync, } from 'node:fs';
import { join, isAbsolute, normalize, relative, resolve } from 'node:path';
import { STALE_VERSION_FLAT } from '../util/command-helpers.js';
import { updateManagedBlocks } from './managed-blocks.js';
/** Creates a directory (and all parents) if it does not already exist. */
export function ensureDir(dirPath) {
    if (!existsSync(dirPath))
        mkdirSync(dirPath, { recursive: true });
}
/**
 * Resolves a user-provided relative path ensuring it stays inside the workspace root.
 * Throws if the path is absolute, escapes via `../`, or resolves to the root itself.
 */
export function resolveWorkspacePath(root, relativePath) {
    if (isAbsolute(relativePath)) {
        throw new Error(`Path must be relative, got absolute: ${relativePath}`);
    }
    const normalizedRoot = resolve(root);
    const resolved = resolve(normalizedRoot, normalize(relativePath));
    const rel = relative(normalizedRoot, resolved);
    if (rel === '' || rel.startsWith('..') || isAbsolute(rel)) {
        throw new Error(`Path must stay inside workspace root: ${relativePath}`);
    }
    return resolved;
}
/**
 * Writes content to a file atomically by first writing to a temp file in the
 * same directory, then renaming. Prevents corruption if the process crashes mid-write.
 */
export function writeFileAtomic(filePath, content) {
    const tempPath = `${filePath}.${Date.now()}.tmp`;
    writeFileSync(tempPath, content, 'utf8');
    try {
        renameSync(tempPath, filePath);
    }
    catch (err) {
        try {
            unlinkSync(tempPath);
        }
        catch {
            // best-effort cleanup
        }
        throw err;
    }
}
// ─── File locking ──────────────────────────────────────────────────────────
const STALE_LOCK_TIMEOUT_MS = 10_000;
const MAX_LOCK_RETRIES = 50;
const BASE_RETRY_DELAY_MS = 10;
function isLockStale(lockPath, timeoutMs) {
    try {
        const { mtimeMs } = statSync(lockPath);
        return Date.now() - mtimeMs > timeoutMs;
    }
    catch {
        return false;
    }
}
/**
 * Executes `fn` while holding a directory-based file lock.
 * Uses mkdir for atomic acquisition; retries with backoff on contention.
 */
export function withFileLock(filePath, fn, timeoutMs = STALE_LOCK_TIMEOUT_MS) {
    const lockPath = `${filePath}.lock`;
    acquireLock(lockPath, timeoutMs);
    try {
        return fn();
    }
    finally {
        try {
            rmdirSync(lockPath);
        }
        catch {
            /* already released */
        }
    }
}
function acquireLock(lockPath, timeoutMs) {
    for (let attempt = 0; attempt < MAX_LOCK_RETRIES; attempt++) {
        if (tryAcquireLockOnce(lockPath, timeoutMs))
            return;
    }
    throw new Error(`Failed to acquire file lock after ${MAX_LOCK_RETRIES} retries: ${lockPath}`);
}
function tryAcquireLockOnce(lockPath, timeoutMs) {
    try {
        mkdirSync(lockPath);
        return true;
    }
    catch (err) {
        if (err.code !== 'EEXIST')
            throw err;
        if (isLockStale(lockPath, timeoutMs)) {
            try {
                rmdirSync(lockPath);
            }
            catch {
                /* concurrent removal */
            }
            return false;
        }
        const delay = BASE_RETRY_DELAY_MS * (1 + Math.random());
        const end = Date.now() + delay;
        while (Date.now() < end) {
            /* spin */
        }
        return false;
    }
}
/** Reads a JSONL file and returns parsed records. Returns [] if the file does not exist or is empty. */
export function readJsonlFile(filePath) {
    if (!existsSync(filePath))
        return [];
    const content = readFileSync(filePath, 'utf8').trim();
    if (!content)
        return [];
    return content
        .split('\n')
        .filter(Boolean)
        .map((line) => JSON.parse(line));
}
/** Appends a record as a JSON line to a JSONL file, creating the file and its parent dirs if needed. */
export function appendJsonlFile(filePath, record) {
    ensureDir(join(filePath, '..'));
    withFileLock(filePath, () => {
        if (!existsSync(filePath))
            writeFileSync(filePath, '');
        appendFileSync(filePath, `${JSON.stringify(record)}\n`);
    });
}
/**
 * Deduplicates an array of id-keyed records by keeping the last write for each id.
 * Mirrors the append-log pattern used throughout JSONL state files.
 */
export function dedupeById(records) {
    const latest = new Map();
    for (const record of records)
        latest.set(record.id, record);
    return [...latest.values()];
}
/** Strips undefined values from a plain object (used before JSON serialisation). */
export function compact(value) {
    return Object.fromEntries(Object.entries(value).filter(([, v]) => v !== undefined));
}
/**
 * Thin wrapper around node:fs write operations.
 * Centralises the force/skip decision so every setup module
 * shares the same behaviour without duplicating the guard.
 */
export class FileWriter {
    force;
    log;
    warn;
    constructor(opts) {
        this.force = opts.force;
        this.log = opts.log;
        this.warn = opts.warn;
    }
    /**
     * Writes `content` to `filePath`.
     * Skips the write (and calls `warn`) when the file already exists and
     * `force` is false. Returns whether the file was actually written.
     */
    write(filePath, content) {
        const fileExists = existsSync(filePath);
        if (fileExists && !this.force) {
            this.warn(filePath);
            return 'skipped';
        }
        let nextContent = fileExists ? updateManagedBlocks(readFileSync(filePath, 'utf8'), content).content : content;
        // When merging managed blocks the flat root marker stays at the old version — patch it.
        const flatMarkerNew = STALE_VERSION_FLAT.exec(content)?.[0];
        if (fileExists && flatMarkerNew) {
            nextContent = nextContent.replace(STALE_VERSION_FLAT, flatMarkerNew);
        }
        writeFileAtomic(filePath, nextContent);
        this.log(filePath);
        return 'written';
    }
}
//# sourceMappingURL=file-writer.js.map