import fs from "node:fs";
import path from "node:path";
import crypto from "node:crypto";
import { sanitizeGeneratedImage } from "./image-postprocess.js";
import { resolveAttachmentPath } from "./executors/attachment-executor.js";
const IMAGE_MIME = {
    ".png": "image/png",
    ".jpg": "image/jpeg",
    ".jpeg": "image/jpeg",
    ".webp": "image/webp",
};
export class ImageGenerator {
    config;
    baseUrl;
    workspace;
    attachmentsDir;
    constructor(config, baseUrl, workspace) {
        this.config = config;
        this.baseUrl = baseUrl;
        this.workspace = workspace;
        this.attachmentsDir = path.join(workspace, "attachments");
    }
    async generate(args, sender, notify = null) {
        const prompt = typeof args.prompt === "string" ? args.prompt.trim() : "";
        if (!prompt)
            return "Error: prompt is required for image_generation.generate";
        const size = typeof args.size === "string" && args.size.trim()
            ? args.size.trim()
            : this.config.size;
        const caption = typeof args.caption === "string" && args.caption.trim()
            ? args.caption.trim()
            : undefined;
        const autoSend = args.autoSend !== false;
        // Fire-and-forget progress ping so the user sees something happening during
        // the 20-60s fetch. Errors are swallowed — a failed notify must not abort
        // the generation.
        if (notify) {
            const preview = prompt.length > 80 ? prompt.slice(0, 80) + "…" : prompt;
            notify(`🎨 正在生成图片: "${preview}" (${size})`).catch(() => { });
        }
        const url = `${this.baseUrl.replace(/\/+$/, "")}/v1/images/generations`;
        const controller = new AbortController();
        const timer = setTimeout(() => controller.abort(), this.config.timeoutMs);
        let b64;
        try {
            const resp = await fetch(url, {
                method: "POST",
                signal: controller.signal,
                headers: {
                    "Content-Type": "application/json",
                    Authorization: `Bearer ${this.config.apiKey}`,
                },
                body: JSON.stringify({
                    model: this.config.model,
                    prompt,
                    n: 1,
                    size,
                    response_format: "b64_json",
                }),
            });
            if (!resp.ok) {
                const text = await resp.text().catch(() => "");
                return `Error: image endpoint returned ${resp.status} ${resp.statusText}. ${text.slice(0, 400)}`;
            }
            const json = (await resp.json());
            const first = json.data?.[0];
            if (!first?.b64_json) {
                return `Error: image endpoint response missing data[0].b64_json (got keys: ${Object.keys(first ?? {}).join(", ") || "none"}).`;
            }
            b64 = first.b64_json;
        }
        catch (err) {
            if (err.name === "AbortError") {
                return `Error: image generation timed out after ${this.config.timeoutMs}ms.`;
            }
            return `Error: image request failed: ${err instanceof Error ? err.message : String(err)}`;
        }
        finally {
            clearTimeout(timer);
        }
        return this.saveAndDeliver(b64, {
            label: "Generated",
            sizeHint: size,
            caption,
            autoSend,
            sender,
        });
    }
    async edit(args, sender, notify = null) {
        const prompt = typeof args.prompt === "string" ? args.prompt.trim() : "";
        if (!prompt)
            return "Error: prompt is required for image_generation.edit";
        // image is required: either a string (one path) or an array of strings
        // (multi-image fusion). Each path is resolved relative to attachments/.
        const rawImages = args.image;
        const imagePaths = [];
        if (typeof rawImages === "string") {
            if (!rawImages.trim()) {
                return "Error: image path is required for image_generation.edit";
            }
            imagePaths.push(rawImages);
        }
        else if (Array.isArray(rawImages)) {
            if (rawImages.length === 0) {
                return "Error: image array is empty — pass at least one path.";
            }
            if (rawImages.length > 3) {
                return `Error: image[] accepts at most 3 paths (got ${rawImages.length}).`;
            }
            for (const item of rawImages) {
                if (typeof item !== "string" || !item.trim()) {
                    return "Error: every entry in image[] must be a non-empty string path.";
                }
                imagePaths.push(item);
            }
        }
        else {
            return "Error: image is required for image_generation.edit (string path or array of paths under attachments/).";
        }
        let imageFiles;
        try {
            imageFiles = imagePaths.map((p) => this.resolveImageForUpload(p));
        }
        catch (err) {
            return `Error: ${err instanceof Error ? err.message : String(err)}`;
        }
        let maskFile = null;
        if (typeof args.mask === "string" && args.mask.trim()) {
            try {
                maskFile = this.resolveImageForUpload(args.mask);
            }
            catch (err) {
                return `Error (mask): ${err instanceof Error ? err.message : String(err)}`;
            }
        }
        else if (args.mask !== undefined && args.mask !== null) {
            return "Error: mask must be a string path under attachments/, or omitted.";
        }
        const size = typeof args.size === "string" && args.size.trim()
            ? args.size.trim()
            : this.config.size;
        const caption = typeof args.caption === "string" && args.caption.trim()
            ? args.caption.trim()
            : undefined;
        const autoSend = args.autoSend !== false;
        if (notify) {
            const preview = prompt.length > 80 ? prompt.slice(0, 80) + "…" : prompt;
            const imgDesc = imageFiles.length === 1
                ? imageFiles[0].name
                : `${imageFiles.length} images`;
            notify(`🖌️ 正在编辑图片 (${imgDesc}): "${preview}"`).catch(() => { });
        }
        const form = new FormData();
        form.append("model", this.config.model);
        form.append("prompt", prompt);
        form.append("n", "1");
        form.append("size", size);
        form.append("response_format", "b64_json");
        for (const f of imageFiles) {
            const data = fs.readFileSync(f.abs);
            // Construct a Uint8Array view — Blob([Buffer]) works in Node 18+ but
            // a fresh Uint8Array is the most portable form across runtimes.
            const u8 = new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
            form.append("image[]", new Blob([u8], { type: f.mime }), f.name);
        }
        if (maskFile) {
            const data = fs.readFileSync(maskFile.abs);
            const u8 = new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
            form.append("mask", new Blob([u8], { type: maskFile.mime }), maskFile.name);
        }
        const url = `${this.baseUrl.replace(/\/+$/, "")}/v1/images/edits`;
        const controller = new AbortController();
        const timer = setTimeout(() => controller.abort(), this.config.timeoutMs);
        let b64;
        try {
            const resp = await fetch(url, {
                method: "POST",
                signal: controller.signal,
                headers: {
                    // Do NOT set Content-Type — fetch must inject the multipart boundary.
                    Authorization: `Bearer ${this.config.apiKey}`,
                },
                body: form,
            });
            if (!resp.ok) {
                const text = await resp.text().catch(() => "");
                return `Error: image edit endpoint returned ${resp.status} ${resp.statusText}. ${text.slice(0, 400)}`;
            }
            const json = (await resp.json());
            const first = json.data?.[0];
            if (!first?.b64_json) {
                return `Error: image edit response missing data[0].b64_json (got keys: ${Object.keys(first ?? {}).join(", ") || "none"}).`;
            }
            b64 = first.b64_json;
        }
        catch (err) {
            if (err.name === "AbortError") {
                return `Error: image edit timed out after ${this.config.timeoutMs}ms.`;
            }
            return `Error: image edit request failed: ${err instanceof Error ? err.message : String(err)}`;
        }
        finally {
            clearTimeout(timer);
        }
        return this.saveAndDeliver(b64, {
            label: "Edited",
            sizeHint: size,
            caption,
            autoSend,
            sender,
        });
    }
    /**
     * Resolve a user-supplied path against attachments/, confirm the file
     * exists, and pick the right MIME type for the multipart upload. Throws
     * with a user-facing message on any failure.
     */
    resolveImageForUpload(input) {
        const abs = resolveAttachmentPath(this.workspace, input);
        if (!fs.existsSync(abs)) {
            throw new Error(`image not found: ${input}`);
        }
        const stat = fs.statSync(abs);
        if (stat.isDirectory()) {
            throw new Error(`not a file: ${input}`);
        }
        const ext = path.extname(abs).toLowerCase();
        const mime = IMAGE_MIME[ext];
        if (!mime) {
            throw new Error(`unsupported image extension '${ext}' for ${input} — allowed: ${Object.keys(IMAGE_MIME).join(", ")}`);
        }
        return { abs, name: path.basename(abs), mime };
    }
    /**
     * Decode base64 response, run C2PA strip + JPEG re-encode, write to
     * attachments/, then auto-deliver if a frontend is registered. Shared
     * by `generate` and `edit` so both flows behave identically downstream.
     */
    async saveAndDeliver(b64, opts) {
        const rawBuf = Buffer.from(b64, "base64");
        const { buffer: buf, ext, note: postNote } = await sanitizeGeneratedImage(rawBuf);
        if (postNote) {
            console.warn(`[image-generator] post-process: ${postNote}`);
        }
        fs.mkdirSync(this.attachmentsDir, { recursive: true });
        const filename = `generated-${Date.now()}-${crypto.randomBytes(4).toString("hex")}.${ext}`;
        const abs = path.join(this.attachmentsDir, filename);
        fs.writeFileSync(abs, buf);
        const sizeKb = (buf.length / 1024).toFixed(1);
        const savedLine = `${opts.label} image saved as ${filename} (${sizeKb} KB, ${opts.sizeHint}).`;
        if (!opts.autoSend) {
            return `${savedLine} Auto-send skipped (autoSend=false). Use attachment.send to deliver.`;
        }
        if (!opts.sender) {
            return `${savedLine} Auto-send skipped: no frontend registered (e.g., scheduled task context). The filename will appear in the task completion notification.`;
        }
        try {
            await opts.sender({ path: abs, caption: opts.caption, asImage: true });
        }
        catch (err) {
            return `${savedLine} Auto-send FAILED: ${err instanceof Error ? err.message : String(err)}. File is on disk — you can retry with attachment.send.`;
        }
        return `${savedLine} Sent to user as photo${opts.caption ? " with caption." : "."}`;
    }
}
//# sourceMappingURL=image-generator.js.map