// Persistent per-session title overrides. The operator renames any row from
// the sidebar / detail pane; the title persists across manager restarts and
// across `--resume` PTY swaps (the canonical sessionId stays constant).
//
// Lifecycle:
//   - Boot reads `<accountDir>/session-titles.json` into the in-memory Map.
//   - `set(sessionId, title)` writes the map and atomically persists.
//   - `remove(sessionId)` mutates on session purge / delete cascade.
//   - File corruption / missing parent dir / permission errors at boot are
//     logged and treated as empty map; the manager NEVER refuses to start.
//
// File format (UTF-8 JSON):
//   { "<sessionId>": { "title": "...", "renamedAt": "2026-05-22T18:00:00.000Z" } }
//
// Validation lives here (single source of truth). The /rename HTTP route
// imports `validateUserTitle`; the UI does length-only checks for ergonomics
// (the binding gate is the manager route — see SKILL: writer/reader char-set
// drift learning).
import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
const MAX_TITLE_LENGTH = 200;
const CONTROL_CHAR_RE = /[\x00-\x1f\x7f]/;
/** Single source of truth for user-title validation. Mirrored in
 *  Sidebar.tsx + SessionMetadataPane.tsx as length-only client-side checks;
 *  the control-char rejection is server-only. */
export function validateUserTitle(raw) {
    if (typeof raw !== 'string')
        return { ok: false, reason: 'empty' };
    const trimmed = raw.trim();
    if (trimmed.length === 0)
        return { ok: false, reason: 'empty' };
    if (trimmed.length > MAX_TITLE_LENGTH)
        return { ok: false, reason: 'too-long' };
    if (CONTROL_CHAR_RE.test(trimmed))
        return { ok: false, reason: 'control-chars' };
    return { ok: true, title: trimmed };
}
export class UserTitleStore {
    bySessionId = new Map();
    filePath;
    logger;
    loaded = false;
    constructor(filePath, logger) {
        this.filePath = filePath;
        this.logger = logger;
    }
    /** Read the persisted file into the in-memory map. Boot-resilient: any
     *  failure (missing file, malformed JSON, permission denied) is logged
     *  and the store starts empty. Never throws. */
    loadFromDisk() {
        this.loaded = true;
        if (!existsSync(this.filePath)) {
            this.logger(`[claude-session-manager] user-title-store-load empty reason=file-missing path=${this.filePath}`);
            return;
        }
        let raw;
        try {
            raw = readFileSync(this.filePath, 'utf-8');
        }
        catch (err) {
            const detail = err instanceof Error ? err.message : String(err);
            this.logger(`[claude-session-manager] user-title-store-load failed reason=read-error detail=${detail.slice(0, 200)} — starting empty`);
            return;
        }
        let parsed;
        try {
            parsed = JSON.parse(raw);
        }
        catch (err) {
            const detail = err instanceof Error ? err.message : String(err);
            this.logger(`[claude-session-manager] user-title-store-load failed reason=malformed-json detail=${detail.slice(0, 200)} — starting empty`);
            return;
        }
        if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
            this.logger(`[claude-session-manager] user-title-store-load failed reason=not-an-object — starting empty`);
            return;
        }
        let loaded = 0;
        for (const [sessionId, value] of Object.entries(parsed)) {
            if (typeof sessionId !== 'string' || sessionId.length === 0)
                continue;
            if (!value || typeof value !== 'object')
                continue;
            const v = value;
            if (typeof v.title !== 'string' || v.title.trim().length === 0)
                continue;
            const trimmed = v.title.trim();
            if (trimmed.length > MAX_TITLE_LENGTH)
                continue;
            if (CONTROL_CHAR_RE.test(trimmed))
                continue;
            const renamedAt = typeof v.renamedAt === 'string' && v.renamedAt.length > 0 ? v.renamedAt : new Date(0).toISOString();
            this.bySessionId.set(sessionId, { title: trimmed, renamedAt });
            loaded += 1;
        }
        this.logger(`[claude-session-manager] user-title-store-load ok entries=${loaded} path=${this.filePath}`);
    }
    get(sessionId) {
        return this.bySessionId.get(sessionId);
    }
    /** Persist `title` for `sessionId`. Caller is responsible for validating
     *  via `validateUserTitle` first; the store stores whatever it's given. */
    set(sessionId, title) {
        const entry = { title, renamedAt: new Date().toISOString() };
        this.bySessionId.set(sessionId, entry);
        this.persistAtomic();
        return entry;
    }
    /** Remove the entry for `sessionId` if present. Called on session
     *  purge/delete cascade so the file doesn't accrete orphans.
     *  Returns true when an entry was removed. */
    remove(sessionId) {
        const had = this.bySessionId.delete(sessionId);
        if (had)
            this.persistAtomic();
        return had;
    }
    /** Current in-memory snapshot — diagnostic only. */
    size() {
        return this.bySessionId.size;
    }
    persistAtomic() {
        const obj = {};
        for (const [sessionId, entry] of this.bySessionId)
            obj[sessionId] = entry;
        const tmp = `${this.filePath}.tmp`;
        try {
            writeFileSync(tmp, JSON.stringify(obj, null, 2), 'utf-8');
            renameSync(tmp, this.filePath);
        }
        catch (err) {
            const detail = err instanceof Error ? err.message : String(err);
            this.logger(`[claude-session-manager] user-title-store-persist failed detail=${detail.slice(0, 200)}`);
            // No rethrow — a rename whose persist failed still serves reads from
            // the in-memory map until next boot. Boot-resilient on next start.
        }
    }
}
//# sourceMappingURL=user-title-store.js.map