import { mkdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { buildGeminiWebModelHeader, FALLBACK_GEMINI_WEB_MODEL, } from "./models.js";
const USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
const MODEL_HEADER_NAME = "x-goog-ext-525001261-jspb";
const GEMINI_APP_URL = "https://gemini.google.com/app";
const GEMINI_STREAM_GENERATE_URL = "https://gemini.google.com/_/BardChatUi/data/assistant.lamda.BardFrontendService/StreamGenerate";
const GEMINI_UPLOAD_URL = "https://content-push.googleapis.com/upload";
const GEMINI_UPLOAD_PUSH_ID = "feeds/mcudyrk2a4khkz";
const GEMINI_UPLOAD_MIME_TYPES = {
    ".bmp": "image/bmp",
    ".gif": "image/gif",
    ".jpeg": "image/jpeg",
    ".jpg": "image/jpeg",
    ".mov": "video/quicktime",
    ".mp4": "video/mp4",
    ".pdf": "application/pdf",
    ".png": "image/png",
    ".svg": "image/svg+xml",
    ".webm": "video/webm",
    ".webp": "image/webp",
};
/**
 * Resolve the MIME type Gemini should be told an upload carries.
 *
 * Gemini silently discards uploads it cannot type: the run still reports the file as
 * attached, but the model never receives it and answers as though nothing was sent.
 * Anything falling back to `application/octet-stream` is therefore invisible to the model.
 *
 * Only formats confirmed to work against the Gemini web upload endpoint are listed. The
 * endpoint also gates on the file extension, not just the declared type — an `.m4v` byte
 * for byte identical to a working `.mp4`, and declared `video/mp4`, is still dropped — so
 * entries here cannot be extrapolated from what the Gemini API documents.
 */
export function resolveGeminiUploadMimeType(filePath) {
    return (GEMINI_UPLOAD_MIME_TYPES[path.extname(filePath).toLowerCase()] ?? "application/octet-stream");
}
function getNestedValue(value, pathParts, fallback) {
    let current = value;
    for (const part of pathParts) {
        if (current == null)
            return fallback;
        if (typeof part === "number") {
            if (!Array.isArray(current))
                return fallback;
            current = current[part];
        }
        else {
            if (typeof current !== "object")
                return fallback;
            current = current[part];
        }
    }
    return current ?? fallback;
}
function buildCookieHeader(cookieMap) {
    return Object.entries(cookieMap)
        .filter(([, value]) => typeof value === "string" && value.length > 0)
        .map(([name, value]) => `${name}=${value}`)
        .join("; ");
}
export async function fetchGeminiAccessToken(cookieMap, signal) {
    const cookieHeader = buildCookieHeader(cookieMap);
    const res = await fetch(GEMINI_APP_URL, {
        redirect: "follow",
        signal,
        headers: {
            cookie: cookieHeader,
            "user-agent": USER_AGENT,
        },
    });
    const html = await res.text();
    const tokens = ["SNlM0e", "thykhd"];
    for (const key of tokens) {
        const match = html.match(new RegExp(`"${key}":"(.*?)"`));
        if (match?.[1])
            return match[1];
    }
    throw new Error("Unable to locate Gemini access token on gemini.google.com/app (missing SNlM0e/thykhd).");
}
function trimGeminiJsonEnvelope(text) {
    const start = text.indexOf("[");
    const end = text.lastIndexOf("]");
    if (start === -1 || end === -1 || end <= start) {
        throw new Error("Gemini response did not contain a JSON payload.");
    }
    return text.slice(start, end + 1);
}
function extractErrorCode(responseJson) {
    const code = getNestedValue(responseJson, [0, 5, 2, 0, 1, 0], -1);
    return typeof code === "number" && code >= 0 ? code : undefined;
}
function extractGgdlUrls(rawText) {
    const matches = rawText.match(/https:\/\/lh3\.googleusercontent\.com\/gg-dl\/[^\s"']+/g) ?? [];
    const seen = new Set();
    const urls = [];
    for (const match of matches) {
        if (seen.has(match))
            continue;
        seen.add(match);
        urls.push(match);
    }
    return urls;
}
function ensureFullSizeImageUrl(url) {
    if (url.includes("=s2048"))
        return url;
    if (url.includes("=s"))
        return url;
    return `${url}=s2048`;
}
async function fetchWithCookiePreservingRedirects(url, init, signal, maxRedirects = 10) {
    let current = url;
    for (let i = 0; i <= maxRedirects; i += 1) {
        const res = await fetch(current, { ...init, redirect: "manual", signal });
        if (res.status >= 300 && res.status < 400) {
            const location = res.headers.get("location");
            if (!location)
                return res;
            current = new URL(location, current).toString();
            continue;
        }
        return res;
    }
    throw new Error(`Too many redirects while downloading image (>${maxRedirects}).`);
}
async function downloadGeminiImage(url, cookieMap, outputPath, signal) {
    const cookieHeader = buildCookieHeader(cookieMap);
    const res = await fetchWithCookiePreservingRedirects(ensureFullSizeImageUrl(url), {
        headers: {
            cookie: cookieHeader,
            "user-agent": USER_AGENT,
        },
    }, signal);
    if (!res.ok) {
        throw new Error(`Failed to download image: ${res.status} ${res.statusText} (${res.url})`);
    }
    const data = new Uint8Array(await res.arrayBuffer());
    await mkdir(path.dirname(outputPath), { recursive: true });
    await writeFile(outputPath, data);
}
async function uploadGeminiFile(filePath, signal) {
    const absPath = path.resolve(process.cwd(), filePath);
    const data = await readFile(absPath);
    const fileName = path.basename(absPath);
    const mimeType = resolveGeminiUploadMimeType(absPath);
    const form = new FormData();
    form.append("file", new Blob([data], { type: mimeType }), fileName);
    const res = await fetch(GEMINI_UPLOAD_URL, {
        method: "POST",
        redirect: "follow",
        signal,
        headers: {
            "push-id": GEMINI_UPLOAD_PUSH_ID,
            "user-agent": USER_AGENT,
        },
        body: form,
    });
    const text = await res.text();
    if (!res.ok) {
        throw new Error(`File upload failed: ${res.status} ${res.statusText} (${text.slice(0, 200)})`);
    }
    return { id: text, name: fileName, mimeType };
}
function buildGeminiFReqPayload(prompt, uploaded, chatMetadata) {
    const promptPayload = uploaded.length > 0
        ? [
            prompt,
            0,
            null,
            // Format: [[[fileId, 1, null, "mimeType"], "filename", ...]]
            uploaded.map((file) => [[file.id, 1, null, file.mimeType], file.name]),
        ]
        : [prompt];
    const innerList = [promptPayload, null, chatMetadata ?? null];
    return JSON.stringify([null, JSON.stringify(innerList)]);
}
export function parseGeminiStreamGenerateResponse(rawText) {
    const responseJson = JSON.parse(trimGeminiJsonEnvelope(rawText));
    const errorCode = extractErrorCode(responseJson);
    const parts = Array.isArray(responseJson) ? responseJson : [];
    let bodyIndex = 0;
    let body = null;
    for (let i = 0; i < parts.length; i += 1) {
        const partBody = getNestedValue(parts[i], [2], null);
        if (!partBody)
            continue;
        try {
            const parsed = JSON.parse(partBody);
            const candidateList = getNestedValue(parsed, [4], []);
            if (Array.isArray(candidateList) && candidateList.length > 0) {
                const candidateText = getNestedValue(candidateList[0], [1, 0], "");
                const hasText = typeof candidateText === "string" && candidateText.length > 0;
                if (body === null) {
                    bodyIndex = i;
                    body = parsed;
                }
                else if (hasText) {
                    body = parsed;
                }
            }
        }
        catch {
            // ignore
        }
    }
    const candidateList = getNestedValue(body, [4], []);
    const firstCandidate = candidateList[0];
    const textRaw = getNestedValue(firstCandidate, [1, 0], "");
    const cardContent = /^http:\/\/googleusercontent\.com\/card_content\/\d+/.test(textRaw);
    const text = cardContent
        ? (getNestedValue(firstCandidate, [22, 0], null) ?? textRaw)
        : textRaw;
    const thoughts = getNestedValue(firstCandidate, [37, 0, 0], null);
    const metadata = getNestedValue(body, [1], []);
    const images = [];
    const webImages = getNestedValue(firstCandidate, [12, 1], []);
    for (const webImage of webImages) {
        const url = getNestedValue(webImage, [0, 0, 0], null);
        if (!url)
            continue;
        images.push({
            kind: "web",
            url,
            title: getNestedValue(webImage, [7, 0], undefined),
            alt: getNestedValue(webImage, [0, 4], undefined),
        });
    }
    const hasGenerated = Boolean(getNestedValue(firstCandidate, [12, 7, 0], null));
    if (hasGenerated) {
        let imgBody = null;
        for (let i = bodyIndex; i < parts.length; i += 1) {
            const partBody = getNestedValue(parts[i], [2], null);
            if (!partBody)
                continue;
            try {
                const parsed = JSON.parse(partBody);
                const candidateImages = getNestedValue(parsed, [4, 0, 12, 7, 0], null);
                if (candidateImages != null) {
                    imgBody = parsed;
                    break;
                }
            }
            catch {
                // ignore
            }
        }
        const imgCandidate = getNestedValue(imgBody ?? body, [4, 0], null);
        const generated = getNestedValue(imgCandidate, [12, 7, 0], []);
        for (const genImage of generated) {
            const url = getNestedValue(genImage, [0, 3, 3], null);
            if (!url)
                continue;
            images.push({
                kind: "generated",
                url,
                title: "[Generated Image]",
                alt: "",
            });
        }
    }
    return { metadata, text, thoughts, images, errorCode };
}
export function isGeminiModelUnavailable(errorCode) {
    return errorCode === 1052;
}
export async function runGeminiWebOnce(input) {
    const cookieHeader = buildCookieHeader(input.cookieMap);
    const at = await fetchGeminiAccessToken(input.cookieMap, input.signal);
    const uploaded = [];
    for (const file of input.files ?? []) {
        if (input.signal?.aborted) {
            throw new Error("Gemini web run aborted before upload.");
        }
        uploaded.push(await uploadGeminiFile(file, input.signal));
    }
    const fReq = buildGeminiFReqPayload(input.prompt, uploaded, input.chatMetadata ?? null);
    const params = new URLSearchParams();
    params.set("at", at);
    params.set("f.req", fReq);
    const res = await fetch(GEMINI_STREAM_GENERATE_URL, {
        method: "POST",
        redirect: "follow",
        signal: input.signal,
        headers: {
            "content-type": "application/x-www-form-urlencoded;charset=utf-8",
            origin: "https://gemini.google.com",
            referer: "https://gemini.google.com/",
            "x-same-domain": "1",
            "user-agent": USER_AGENT,
            cookie: cookieHeader,
            [MODEL_HEADER_NAME]: buildGeminiWebModelHeader(input.model),
        },
        body: params.toString(),
    });
    const rawResponseText = await res.text();
    if (!res.ok) {
        return {
            rawResponseText,
            text: "",
            thoughts: null,
            metadata: input.chatMetadata ?? null,
            images: [],
            errorMessage: `Gemini request failed: ${res.status} ${res.statusText}`,
        };
    }
    try {
        const parsed = parseGeminiStreamGenerateResponse(rawResponseText);
        return {
            rawResponseText,
            text: parsed.text ?? "",
            thoughts: parsed.thoughts,
            metadata: parsed.metadata,
            images: parsed.images,
            errorCode: parsed.errorCode,
        };
    }
    catch (error) {
        let responseJson = null;
        try {
            responseJson = JSON.parse(trimGeminiJsonEnvelope(rawResponseText));
        }
        catch {
            responseJson = null;
        }
        const errorCode = extractErrorCode(responseJson);
        return {
            rawResponseText,
            text: "",
            thoughts: null,
            metadata: input.chatMetadata ?? null,
            images: [],
            errorCode: typeof errorCode === "number" ? errorCode : undefined,
            errorMessage: error instanceof Error ? error.message : String(error ?? ""),
        };
    }
}
export async function runGeminiWebWithFallback(input) {
    const attempt = await runGeminiWebOnce(input);
    if (isGeminiModelUnavailable(attempt.errorCode) && input.model !== FALLBACK_GEMINI_WEB_MODEL) {
        const fallback = await runGeminiWebOnce({ ...input, model: FALLBACK_GEMINI_WEB_MODEL });
        return { ...fallback, effectiveModel: FALLBACK_GEMINI_WEB_MODEL };
    }
    return { ...attempt, effectiveModel: input.model };
}
export async function saveFirstGeminiImageFromOutput(output, cookieMap, outputPath, signal) {
    const generatedOrWeb = output.images.find((img) => img.kind === "generated") ?? output.images[0];
    if (generatedOrWeb?.url) {
        await downloadGeminiImage(generatedOrWeb.url, cookieMap, outputPath, signal);
        return { saved: true, imageCount: output.images.length };
    }
    const ggdl = extractGgdlUrls(output.rawResponseText);
    if (ggdl[0]) {
        await downloadGeminiImage(ggdl[0], cookieMap, outputPath, signal);
        return { saved: true, imageCount: ggdl.length };
    }
    return { saved: false, imageCount: 0 };
}
