/**
 * Persistence for user-added local model endpoints.
 *
 * The four well-known servers (Ollama, LM Studio, llama.cpp, vLLM) are probed
 * without configuration; this store holds only the extra endpoints a user typed
 * in — a moved port, a remote self-hosted box, an OpenAI-compatible gateway.
 *
 * Endpoints live in `~/.gg/gg-app.json` under `localEndpoints`; each one's key
 * (and, for keyed servers, its token) lives in `~/.gg/auth.json` as a
 * `local:<id>` credential, which is also what carries the baseUrl into the
 * stream call. The two are kept in sync here so the UI never has to.
 */
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { AuthStorage, DEFAULT_LOCAL_ENDPOINTS, clearLocalDiscoveryCache, } from "@kenkaiiii/gg-core";
export class LocalEndpointError extends Error {
}
const RESERVED_IDS = new Set(DEFAULT_LOCAL_ENDPOINTS.map((e) => e.id));
const MAX_CUSTOM_ENDPOINTS = 20;
function appSettingsFile(override) {
    return override ?? path.join(os.homedir(), ".gg", "gg-app.json");
}
/**
 * Normalize a user-typed URL into the OpenAI-compatible base we call.
 * Accepts `host:port`, a bare root, or a full `/v1` URL, and tolerates a pasted
 * `/v1/chat/completions` or `/v1/models` (people copy from docs).
 */
export function normalizeLocalBaseUrl(input) {
    const trimmed = input.trim();
    if (!trimmed)
        throw new LocalEndpointError("Endpoint URL is required.");
    const scheme = /^([a-z][a-z0-9+.-]*):\/\//i.exec(trimmed)?.[1]?.toLowerCase();
    if (scheme && scheme !== "http" && scheme !== "https") {
        throw new LocalEndpointError("Endpoint URL must use http or https.");
    }
    // Bare `host:port` is the common case — assume plain http, like the servers do.
    const withScheme = scheme ? trimmed : `http://${trimmed}`;
    let url;
    try {
        url = new URL(withScheme);
    }
    catch {
        throw new LocalEndpointError(`"${input}" is not a valid URL.`);
    }
    if (url.protocol !== "http:" && url.protocol !== "https:") {
        throw new LocalEndpointError("Endpoint URL must use http or https.");
    }
    if (!url.hostname)
        throw new LocalEndpointError("Endpoint URL is missing a host.");
    let pathname = url.pathname.replace(/\/+$/, "");
    pathname = pathname.replace(/\/(?:chat\/completions|completions|models)$/, "");
    if (!/\/v\d+$/.test(pathname))
        pathname = `${pathname}/v1`;
    return `${url.protocol}//${url.host}${pathname}`;
}
/** Slug derived from the URL, e.g. `custom-192-168-1-4-8000`. Stable per host:port. */
function endpointIdFor(baseUrl) {
    const { host } = new URL(baseUrl);
    const slug = host
        .replace(/[^a-z0-9]+/gi, "-")
        .replace(/^-|-$/g, "")
        .toLowerCase();
    return `custom-${slug || "endpoint"}`;
}
function labelFor(baseUrl, label) {
    const trimmed = label?.trim();
    if (trimmed)
        return trimmed;
    return new URL(baseUrl).host;
}
// ── settings file I/O ──────────────────────────────────────
/**
 * Read/modify/write the whole settings object rather than a typed subset, so a
 * key written by another part of the app (or a newer version) survives.
 */
async function readSettings(file) {
    try {
        const raw = JSON.parse(await fs.readFile(appSettingsFile(file), "utf-8"));
        return raw && typeof raw === "object" ? raw : {};
    }
    catch {
        return {};
    }
}
async function writeSettings(settings, file) {
    const target = appSettingsFile(file);
    await fs.mkdir(path.dirname(target), { recursive: true });
    await fs.writeFile(target, JSON.stringify(settings, null, 2), "utf-8");
}
function parseStored(value) {
    if (!Array.isArray(value))
        return [];
    const endpoints = [];
    for (const entry of value) {
        if (!entry || typeof entry !== "object")
            continue;
        const { id, label, baseUrl, apiKey } = entry;
        if (typeof id !== "string" || typeof baseUrl !== "string")
            continue;
        endpoints.push({
            id,
            label: typeof label === "string" && label ? label : baseUrl,
            baseUrl,
            kind: "custom",
            custom: true,
            ...(typeof apiKey === "string" && apiKey ? { apiKey } : {}),
        });
    }
    return endpoints;
}
/** User-added endpoints, in insertion order. Never throws on a corrupt file. */
export async function listCustomEndpoints(options = {}) {
    return parseStored((await readSettings(options.settingsFile))["localEndpoints"]);
}
/** Well-known endpoints first, then the user's own — the probe/scan order. */
export async function listAllEndpoints(options = {}) {
    return [...DEFAULT_LOCAL_ENDPOINTS, ...(await listCustomEndpoints(options))];
}
/**
 * Add a custom endpoint and write its `local:<id>` credential. Re-adding the
 * same host updates it (that's how a user fixes a mistyped key) instead of
 * creating a second row for the same server.
 */
export async function addCustomEndpoint(input, options = {}) {
    const auth = options.auth ?? new AuthStorage();
    const baseUrl = normalizeLocalBaseUrl(input.baseUrl);
    const id = endpointIdFor(baseUrl);
    if (RESERVED_IDS.has(id)) {
        throw new LocalEndpointError(`"${id}" is a built-in endpoint id.`);
    }
    const existing = await listCustomEndpoints(options);
    const builtIn = DEFAULT_LOCAL_ENDPOINTS.find((e) => e.baseUrl === baseUrl);
    if (builtIn) {
        throw new LocalEndpointError(`${baseUrl} is already probed automatically as ${builtIn.label}.`);
    }
    if (!existing.some((e) => e.id === id) && existing.length >= MAX_CUSTOM_ENDPOINTS) {
        throw new LocalEndpointError(`At most ${MAX_CUSTOM_ENDPOINTS} custom endpoints.`);
    }
    const endpoint = {
        id,
        label: labelFor(baseUrl, input.label),
        baseUrl,
        kind: "custom",
        custom: true,
        ...(input.apiKey?.trim() ? { apiKey: input.apiKey.trim() } : {}),
    };
    const next = existing.some((e) => e.id === id)
        ? existing.map((e) => (e.id === id ? endpoint : e))
        : [...existing, endpoint];
    const settings = await readSettings(options.settingsFile);
    settings["localEndpoints"] = next.map(({ id: eid, label, baseUrl: url, apiKey }) => ({
        id: eid,
        label,
        baseUrl: url,
        ...(apiKey ? { apiKey } : {}),
    }));
    await writeSettings(settings, options.settingsFile);
    await auth.setLocalEndpoint(id, baseUrl, endpoint.apiKey);
    clearLocalDiscoveryCache();
    return endpoint;
}
/**
 * Remove a custom endpoint and its credential. Built-in endpoints can't be
 * removed (they're discovered, not configured) — that's an error, not a no-op,
 * so a UI bug doesn't silently do nothing.
 */
export async function removeCustomEndpoint(id, options = {}) {
    const auth = options.auth ?? new AuthStorage();
    if (RESERVED_IDS.has(id)) {
        throw new LocalEndpointError(`${id} is a built-in endpoint and can't be removed.`);
    }
    const existing = await listCustomEndpoints(options);
    if (!existing.some((e) => e.id === id)) {
        throw new LocalEndpointError(`Unknown local endpoint "${id}".`);
    }
    const settings = await readSettings(options.settingsFile);
    settings["localEndpoints"] = existing
        .filter((e) => e.id !== id)
        .map(({ id: eid, label, baseUrl, apiKey }) => ({
        id: eid,
        label,
        baseUrl,
        ...(apiKey ? { apiKey } : {}),
    }));
    await writeSettings(settings, options.settingsFile);
    await auth.removeLocalEndpoint(id);
    clearLocalDiscoveryCache();
}
/**
 * Make sure every endpoint we're about to serve models from has a credential
 * carrying its baseUrl — otherwise `stream()` has no endpoint to talk to.
 * Called after each scan, so a fresh install works with zero setup.
 */
export async function syncEndpointCredentials(endpoints, options = {}) {
    const auth = options.auth ?? new AuthStorage();
    for (const endpoint of endpoints) {
        await auth.setLocalEndpoint(endpoint.id, endpoint.baseUrl, endpoint.apiKey);
    }
}
//# sourceMappingURL=local-endpoint-store.js.map