import fs from "node:fs";
import path from "node:path";
import { createGzip } from "node:zlib";
import { getConfigDir } from "./config/index.js";
import { sanitizeJsonValueInPlace } from "./utils/json-sanitize.js";
// ── ANSI colors for console ────────────────────────────────────────────
const DIM = "\x1b[2m";
const RESET = "\x1b[0m";
const BOLD = "\x1b[1m";
const CYAN = "\x1b[36m";
const YELLOW = "\x1b[33m";
const RED = "\x1b[31m";
const GREEN = "\x1b[32m";
const MAGENTA = "\x1b[35m";
const BLUE = "\x1b[34m";
const LEVEL_COLORS = {
    debug: DIM,
    info: CYAN,
    warn: YELLOW,
    error: RED,
};
const CATEGORY_COLORS = {
    wake: GREEN,
    send: BLUE,
    assistant: MAGENTA,
    tool_call: CYAN,
    tool_result: DIM,
    result: BOLD,
    channel: YELLOW,
    system: DIM,
    system_prompt: BLUE,
    error: RED,
};
// ── Logger class ───────────────────────────────────────────────────────
class Logger {
    fileStream = null;
    initialized = false;
    minConsoleLevel = "info";
    currentDate = null;
    logsDir = null;
    listeners = new Set();
    retentionDays = 180;
    levelPriority = {
        debug: 0,
        info: 1,
        warn: 2,
        error: 3,
    };
    /**
     * Initialize file logging. Call once after profile is set.
     */
    init() {
        if (this.initialized)
            return;
        const envLevel = process.env.LOG_LEVEL?.toLowerCase();
        if (envLevel === "debug" ||
            envLevel === "info" ||
            envLevel === "warn" ||
            envLevel === "error") {
            this.minConsoleLevel = envLevel;
        }
        try {
            this.logsDir = path.join(getConfigDir(), "logs");
            if (!fs.existsSync(this.logsDir)) {
                fs.mkdirSync(this.logsDir, { recursive: true });
            }
            this.currentDate = this.formatLocalDate(new Date()); // YYYY-MM-DD (local)
            const logFile = path.join(this.logsDir, `${this.currentDate}.jsonl`);
            this.fileStream = fs.createWriteStream(logFile, { flags: "a" });
            this.cleanupOldLogs();
            this.initialized = true;
            // Compress any uncompressed logs from previous days
            void this.compressOldDays(this.currentDate);
        }
        catch {
            // Can't create log file -- continue with console only
        }
    }
    /**
     * Core log method.
     */
    log(level, category, message, data) {
        const now = new Date();
        const entry = {
            timestamp: this.formatLocalIso(now),
            level,
            category,
            message,
            ...(data ? { data } : {}),
        };
        sanitizeJsonValueInPlace(entry);
        // Write to file (JSONL)
        if (this.fileStream) {
            this.rotateIfNeeded(entry.timestamp);
            this.fileStream.write(JSON.stringify(entry) + "\n");
        }
        // Notify listeners (best-effort)
        if (this.listeners.size > 0) {
            for (const cb of this.listeners) {
                try {
                    cb(entry);
                }
                catch {
                    // ignore listener failures
                }
            }
        }
        // Write to console if level is high enough
        if (this.levelPriority[level] >= this.levelPriority[this.minConsoleLevel]) {
            this.writeConsole(entry);
        }
    }
    /**
     * Subscribe to log events produced by this logger.
     * Returns an unsubscribe function.
     */
    onLog(listener) {
        this.listeners.add(listener);
        return () => {
            this.listeners.delete(listener);
        };
    }
    writeConsole(entry) {
        try {
            const time = entry.timestamp.slice(11, 23); // HH:mm:ss.SSS
            const levelColor = LEVEL_COLORS[entry.level];
            const catColor = CATEGORY_COLORS[entry.category] ?? DIM;
            const prefix = `${DIM}${time}${RESET} ${catColor}[${entry.category}]${RESET}`;
            // Format differently based on category for readability
            switch (entry.category) {
                case "wake":
                    console.log(`\n${DIM}${"─".repeat(70)}${RESET}`);
                    console.log(`${prefix} ${entry.message}`);
                    break;
                case "send":
                    console.log(`${prefix} ${DIM}→${RESET} ${this.truncate(entry.message, 120)}`);
                    break;
                case "assistant":
                    console.log(`${prefix} ${entry.message}`);
                    break;
                case "tool_call": {
                    const toolName = this.str(entry.data?.name);
                    const toolInput = entry.data?.input;
                    const subTag = this.formatSubagentTag(entry.data);
                    const inputStr = toolInput
                        ? ` ${DIM}${this.truncate(JSON.stringify(toolInput), 100)}${RESET}`
                        : "";
                    console.log(`${prefix}${subTag} ${BOLD}${toolName}${RESET}${inputStr}`);
                    break;
                }
                case "tool_result": {
                    const resultName = this.str(entry.data?.name);
                    const subTag = this.formatSubagentTag(entry.data);
                    console.log(`${prefix}${subTag} ${resultName} ${DIM}${this.truncate(entry.message, 100)}${RESET}`);
                    break;
                }
                case "result": {
                    const turns = this.str(entry.data?.turns);
                    const inTok = this.str(entry.data?.input_tokens);
                    const outTok = this.str(entry.data?.output_tokens);
                    const costVal = this.str(entry.data?.cost);
                    console.log(`${prefix} turns=${turns} input=${inTok} output=${outTok} cost=$${costVal}`);
                    console.log(`${DIM}${"─".repeat(70)}${RESET}\n`);
                    break;
                }
                case "error":
                    console.error(`${prefix} ${levelColor}${entry.message}${RESET}`);
                    break;
                case "system_prompt": {
                    const preview = entry.data?.consolePreview;
                    const display = typeof preview === "string" && preview.length > 0
                        ? preview
                        : this.truncate(entry.message, 180);
                    console.log(`${prefix} ${display}`);
                    break;
                }
                default:
                    if (entry.data && Object.keys(entry.data).length > 0) {
                        console.log(`${prefix} ${entry.message} ${DIM}${this.truncate(JSON.stringify(entry.data), 180)}${RESET}`);
                    }
                    else {
                        console.log(`${prefix} ${entry.message}`);
                    }
            }
        }
        catch {
            // Swallow console write errors (e.g. EPIPE when stdout pipe breaks)
            // to prevent an infinite loop: uncaughtException → logger.err →
            // writeConsole → EPIPE → uncaughtException → ...
        }
    }
    formatSubagentTag(data) {
        const parent = data?.parent_tool_use_id;
        const subType = data?.subagent_type;
        const agentId = data?.subagent_agent_id;
        if (!parent && !subType && !agentId)
            return "";
        const parts = [];
        if (typeof subType === "string" && subType.length > 0) {
            parts.push(subType);
        }
        else {
            parts.push("subagent");
        }
        if (typeof agentId === "string" && agentId.length > 0) {
            parts.push(agentId.slice(0, 8));
        }
        // Keep it compact; include the parent tool_use_id only for deep correlation.
        const parentSuffix = typeof parent === "string" && parent.length > 0
            ? ` ${DIM}(parent:${parent.slice(0, 8)})${RESET}`
            : "";
        return ` ${DIM}[${parts.join(":")}]${RESET}${parentSuffix}`;
    }
    truncate(s, maxLen) {
        const oneLine = s.replace(/\n/g, " ").trim();
        if (oneLine.length <= maxLen)
            return oneLine;
        return oneLine.substring(0, maxLen) + "…";
    }
    rotateIfNeeded(timestampIso) {
        if (!this.logsDir || !this.currentDate)
            return;
        const entryDate = timestampIso.slice(0, 10);
        if (entryDate !== this.currentDate) {
            this.reopenForDate(entryDate);
        }
    }
    reopenForDate(date) {
        if (!this.logsDir)
            return;
        try {
            const previousDate = this.currentDate;
            this.closeStream();
            this.currentDate = date;
            const logFile = path.join(this.logsDir, `${this.currentDate}.jsonl`);
            this.fileStream = fs.createWriteStream(logFile, { flags: "a" });
            this.cleanupOldLogs();
            if (previousDate && previousDate !== date) {
                void this.compressOldDays(date);
            }
        }
        catch {
            // Best-effort rotation only
        }
    }
    closeStream() {
        if (this.fileStream) {
            try {
                this.fileStream.end();
            }
            catch {
                // ignore
            }
        }
        this.fileStream = null;
    }
    cleanupOldLogs() {
        if (!this.logsDir)
            return;
        const cutoff = new Date();
        cutoff.setDate(cutoff.getDate() - this.retentionDays);
        const cutoffStr = this.formatLocalDate(cutoff);
        try {
            const entries = fs.readdirSync(this.logsDir);
            for (const entry of entries) {
                const isJsonl = entry.endsWith(".jsonl");
                const isGz = entry.endsWith(".jsonl.gz");
                if (!isJsonl && !isGz)
                    continue;
                const datePart = entry.slice(0, 10);
                if (!/^\d{4}-\d{2}-\d{2}$/.test(datePart))
                    continue;
                if (datePart < cutoffStr) {
                    try {
                        fs.unlinkSync(path.join(this.logsDir, entry));
                    }
                    catch {
                        // ignore individual failures
                    }
                }
            }
        }
        catch {
            // best-effort cleanup only
        }
    }
    /**
     * Compress .jsonl files from days before `todayDate` into .jsonl.gz
     * archives, then delete the raw files.
     * Runs asynchronously in the background — errors are silently ignored.
     */
    async compressOldDays(todayDate) {
        if (!this.logsDir)
            return;
        const logsDir = this.logsDir;
        try {
            const entries = fs.readdirSync(logsDir);
            // Group .jsonl files by date (handles legacy numbered files like 2026-03-30-1.jsonl)
            const byDate = new Map();
            for (const entry of entries) {
                if (!entry.endsWith(".jsonl"))
                    continue;
                const datePart = entry.slice(0, 10);
                if (!/^\d{4}-\d{2}-\d{2}$/.test(datePart))
                    continue;
                if (datePart >= todayDate)
                    continue;
                let list = byDate.get(datePart);
                if (!list) {
                    list = [];
                    byDate.set(datePart, list);
                }
                list.push(entry);
            }
            for (const [datePart, files] of byDate) {
                const gzPath = path.join(logsDir, `${datePart}.jsonl.gz`);
                const rawPaths = files.map((f) => path.join(logsDir, f));
                if (fs.existsSync(gzPath)) {
                    // Already compressed — just remove leftover raw files
                    for (const p of rawPaths) {
                        try {
                            fs.unlinkSync(p);
                        }
                        catch {
                            /* ignore */
                        }
                    }
                    continue;
                }
                // Sort: numbered files first (by number), then the base file last
                files.sort((a, b) => {
                    const numA = /^\d{4}-\d{2}-\d{2}-(\d+)\.jsonl$/.exec(a);
                    const numB = /^\d{4}-\d{2}-\d{2}-(\d+)\.jsonl$/.exec(b);
                    if (numA && numB)
                        return Number(numA[1]) - Number(numB[1]);
                    if (numA)
                        return -1;
                    if (numB)
                        return 1;
                    return 0;
                });
                const sortedPaths = files.map((f) => path.join(logsDir, f));
                const tmpPath = `${gzPath}.tmp`;
                try {
                    await this.compressFiles(sortedPaths, tmpPath);
                    fs.renameSync(tmpPath, gzPath);
                }
                catch {
                    try {
                        fs.unlinkSync(tmpPath);
                    }
                    catch {
                        /* ignore */
                    }
                    continue;
                }
                for (const p of rawPaths) {
                    try {
                        fs.unlinkSync(p);
                    }
                    catch {
                        /* ignore */
                    }
                }
            }
        }
        catch {
            // best-effort — don't let compression failures affect logging
        }
    }
    /**
     * Gzip one or more files (concatenated in order) to the given output path.
     */
    async compressFiles(inputPaths, outputPath) {
        const gzStream = fs.createWriteStream(outputPath);
        const gz = createGzip({ level: 6 });
        gz.pipe(gzStream);
        const done = new Promise((resolve, reject) => {
            gzStream.on("finish", resolve);
            gzStream.on("error", reject);
            gz.on("error", reject);
        });
        for (const inputPath of inputPaths) {
            await new Promise((resolve, reject) => {
                const src = fs.createReadStream(inputPath);
                src.on("error", reject);
                src.on("data", (chunk) => {
                    if (!gz.write(chunk)) {
                        src.pause();
                        gz.once("drain", () => {
                            src.resume();
                        });
                    }
                });
                src.on("end", resolve);
            });
        }
        gz.end();
        await done;
    }
    /** Safely convert an unknown data field to a display string. */
    str(value) {
        if (value === undefined || value === null)
            return "?";
        if (typeof value === "string")
            return value;
        if (typeof value === "number" || typeof value === "boolean")
            return value.toString();
        return JSON.stringify(value);
    }
    formatLocalDate(date) {
        const year = date.getFullYear();
        const month = String(date.getMonth() + 1).padStart(2, "0");
        const day = String(date.getDate()).padStart(2, "0");
        return `${year}-${month}-${day}`;
    }
    formatLocalIso(date) {
        const year = date.getFullYear();
        const month = String(date.getMonth() + 1).padStart(2, "0");
        const day = String(date.getDate()).padStart(2, "0");
        const hour = String(date.getHours()).padStart(2, "0");
        const minute = String(date.getMinutes()).padStart(2, "0");
        const second = String(date.getSeconds()).padStart(2, "0");
        const ms = String(date.getMilliseconds()).padStart(3, "0");
        const tzOffsetMinutes = -date.getTimezoneOffset(); // minutes east of UTC
        const sign = tzOffsetMinutes >= 0 ? "+" : "-";
        const abs = Math.abs(tzOffsetMinutes);
        const tzHours = String(Math.floor(abs / 60)).padStart(2, "0");
        const tzMinutes = String(abs % 60).padStart(2, "0");
        return `${year}-${month}-${day}T${hour}:${minute}:${second}.${ms}${sign}${tzHours}:${tzMinutes}`;
    }
    // ── Convenience methods ──────────────────────────────────────────────
    wake(trigger, messageCount) {
        const reason = trigger === "heartbeat"
            ? "Heartbeat (scheduled wake)"
            : `New message${messageCount > 1 ? `s (${messageCount})` : ""}`;
        this.log("info", "wake", reason, {
            trigger,
            messageCount,
        });
    }
    send(message) {
        this.log("info", "send", message);
    }
    /**
     * Log a multimodal content block array being sent to the model.
     * Extracts text from text blocks and summarizes image/file blocks.
     */
    sendMultimodal(blocks) {
        const parts = [];
        const imageRefs = [];
        for (const block of blocks) {
            if (block.type === "text" && block.text) {
                parts.push(block.text);
            }
            else if (block.type === "image") {
                const src = block.source;
                if (src?.type === "url" && src.url) {
                    imageRefs.push(`image:url(${src.url})`);
                }
                else if (src?.type === "file" && src.file_id) {
                    imageRefs.push(`image:file(${src.file_id})`);
                }
                else {
                    imageRefs.push("image:base64");
                }
            }
        }
        const textContent = parts.join("\n");
        const attachSummary = imageRefs.length > 0 ? ` [+${imageRefs.join(", ")}]` : "";
        // Console: show truncated text + attachment summary
        this.log("info", "send", textContent + attachSummary, {
            text: textContent,
            attachments: imageRefs,
            blockCount: blocks.length,
        });
    }
    /**
     * Emit a screenshot to SSE listeners only (not written to file or console
     * because the base64 payload is too large for persistent logs).
     */
    screenshot(base64) {
        if (this.listeners.size === 0)
            return;
        const entry = {
            timestamp: this.formatLocalIso(new Date()),
            level: "info",
            category: "screenshot",
            message: "Desktop screenshot",
            data: { base64 },
        };
        for (const cb of this.listeners) {
            try {
                cb(entry);
            }
            catch {
                // ignore
            }
        }
    }
    assistant(text) {
        this.log("info", "assistant", text);
    }
    /**
     * Log a non-text content block received from the assistant (e.g. image in response).
     */
    assistantBlock(blockType, summary) {
        this.log("info", "assistant", `[${blockType}] ${summary}`);
    }
    toolCall(name, input, meta) {
        this.log("info", "tool_call", name, {
            name,
            input: input,
            ...(meta ?? {}),
        });
    }
    toolResult(name, output, meta) {
        const m = { name, ...(meta ?? {}) };
        const isSubagent = typeof m.parent_tool_use_id === "string" ||
            typeof m.subagent_type === "string" ||
            typeof m.subagent_agent_id === "string";
        this.log(isSubagent ? "info" : "debug", "tool_result", output, m);
    }
    result(turns, inputTokens, outputTokens, cost) {
        this.log("info", "result", `turns=${turns} input=${inputTokens} output=${outputTokens} cost=$${cost}`, {
            turns,
            input_tokens: inputTokens,
            output_tokens: outputTokens,
            cost,
        });
    }
    channel(channelName, message) {
        this.log("info", "channel", `[${channelName}] ${message}`);
    }
    system(message) {
        this.log("info", "system", message);
    }
    debug(message, data) {
        this.log("debug", "system", message, data);
    }
    info(message, data) {
        this.log("info", "system", message, data);
    }
    warn(message, data) {
        this.log("warn", "system", message, data);
    }
    err(message, data) {
        this.log("error", "error", message, data);
    }
}
// ── Singleton ──────────────────────────────────────────────────────────
export const logger = new Logger();
//# sourceMappingURL=logger.js.map