import { mkdir, readFile, writeFile } from "node:fs/promises";
import { homedir } from "node:os";
import { join } from "node:path";
export const LOBU_CONFIG_DIR = join(homedir(), ".config", "lobu");
export const DEFAULT_CONTEXT_NAME = "lobu";
const DEFAULT_CONTEXT_URL = "https://app.lobu.ai/api/v1";
const CONTEXTS_FILE = join(LOBU_CONFIG_DIR, "config.json");
export const DEFAULT_LOBU_MCP_URL = "https://lobu.ai/mcp";
/**
 * Org slugs are lowercase alphanumerics plus `-`/`_`, no leading/trailing
 * separator. Single source of truth for both the API client's slug guard and
 * `setActiveOrg` validation.
 */
export const ORG_SLUG_PATTERN = /^[a-z0-9](?:[a-z0-9_-]*[a-z0-9])?$/;
/**
 * Validate an org slug against {@link ORG_SLUG_PATTERN}, throwing `Error` with a
 * consistent message when it doesn't match. Callers that need a typed error
 * (e.g. `ApiClientError`) catch and rewrap.
 */
export function validateOrgSlug(slug) {
    if (!ORG_SLUG_PATTERN.test(slug)) {
        throw new Error(`Invalid organization slug "${slug}". Slugs may only contain alphanumeric characters, hyphens, and underscores.`);
    }
    return slug;
}
export async function loadContextConfig() {
    try {
        const raw = await readFile(CONTEXTS_FILE, "utf-8");
        const parsed = JSON.parse(raw);
        return normalizeContextConfig(parsed);
    }
    catch {
        return normalizeContextConfig({});
    }
}
async function saveContextConfig(config) {
    await mkdir(LOBU_CONFIG_DIR, { recursive: true });
    await writeFile(CONTEXTS_FILE, JSON.stringify(config, null, 2), {
        mode: 0o600,
    });
}
export async function getCurrentContextName() {
    const envContext = process.env.LOBU_CONTEXT?.trim();
    if (envContext) {
        return envContext;
    }
    const config = await loadContextConfig();
    return config.currentContext;
}
export async function getActiveOrg(contextName) {
    const envOrg = process.env.LOBU_ORG?.trim();
    if (envOrg)
        return envOrg;
    const config = await loadContextConfig();
    const name = contextName || config.currentContext;
    return config.contexts[name]?.activeOrg;
}
export async function getMemoryUrl(contextName) {
    const envUrl = process.env.LOBU_MEMORY_URL?.trim();
    if (envUrl)
        return normalizeApiUrl(envUrl);
    const config = await loadContextConfig();
    const name = contextName || config.currentContext;
    return normalizeApiUrl(defaultMemoryUrlForContext(config.contexts[name]));
}
export async function setActiveOrg(orgSlug, contextName) {
    const trimmed = orgSlug.trim();
    if (!trimmed) {
        throw new Error("Organization slug cannot be empty.");
    }
    validateOrgSlug(trimmed);
    const config = await loadContextConfig();
    const name = contextName || config.currentContext;
    const context = config.contexts[name];
    if (!context) {
        throw new Error(`Unknown context "${name}".`);
    }
    context.activeOrg = trimmed;
    await saveContextConfig(config);
    return config;
}
export async function setMemoryUrl(memoryUrl, contextName) {
    const trimmed = memoryUrl.trim();
    if (!trimmed) {
        throw new Error("Memory URL cannot be empty.");
    }
    const config = await loadContextConfig();
    const name = contextName || config.currentContext;
    const context = config.contexts[name];
    if (!context) {
        throw new Error(`Unknown context "${name}".`);
    }
    context.memoryUrl = normalizeAndValidateApiUrl(trimmed);
    await saveContextConfig(config);
    return config;
}
export async function resolveContext(preferredContext) {
    const envApiUrl = process.env.LOBU_API_URL?.trim();
    const requestedContext = preferredContext?.trim() || process.env.LOBU_CONTEXT?.trim();
    if (envApiUrl) {
        return {
            name: requestedContext || (await getCurrentContextName()),
            url: normalizeApiUrl(envApiUrl),
            source: "env",
        };
    }
    const config = await loadContextConfig();
    const contextName = requestedContext || config.currentContext;
    const context = config.contexts[contextName];
    if (context) {
        return contextToResolvedContext(contextName, context);
    }
    throw new Error(`Unknown context "${contextName}". Run \`lobu context list\` to see configured contexts.`);
}
export async function addContext(name, url, server) {
    const trimmedName = name.trim();
    if (!trimmedName) {
        throw new Error("Context name cannot be empty.");
    }
    if (trimmedName === DEFAULT_CONTEXT_NAME) {
        throw new Error(`Cannot overwrite the default context "${trimmedName}". Pick a different name.`);
    }
    const config = await loadContextConfig();
    const entry = {
        url: normalizeAndValidateApiUrl(url),
    };
    const lifecycle = normalizeLifecycle(server?.lifecycle);
    const cwd = server?.cwd?.trim();
    // `cwd` only takes effect for managed contexts — getServerConfig() drops
    // everything else. Persisting it on a non-managed context would be a silent
    // no-op, so reject the combination instead of saving dead config.
    if (cwd && lifecycle !== "managed") {
        throw new Error("`cwd` can only be set on managed contexts.");
    }
    if (lifecycle)
        entry.lifecycle = lifecycle;
    if (cwd)
        entry.cwd = cwd;
    config.contexts[trimmedName] = entry;
    await saveContextConfig(config);
    return config;
}
export async function removeContext(name) {
    const trimmedName = name.trim();
    if (!trimmedName) {
        throw new Error("Context name cannot be empty.");
    }
    const config = await loadContextConfig();
    if (!config.contexts[trimmedName]) {
        // Idempotent: removing a non-existent context is a no-op.
        return config;
    }
    if (trimmedName === DEFAULT_CONTEXT_NAME) {
        throw new Error(`Cannot remove the default context "${trimmedName}".`);
    }
    delete config.contexts[trimmedName];
    if (config.currentContext === trimmedName) {
        config.currentContext = DEFAULT_CONTEXT_NAME;
    }
    await saveContextConfig(config);
    return config;
}
export async function setCurrentContext(name) {
    const trimmedName = name.trim();
    if (!trimmedName) {
        throw new Error("Context name cannot be empty.");
    }
    const config = await loadContextConfig();
    if (!config.contexts[trimmedName]) {
        throw new Error(`Unknown context "${trimmedName}". Run \`lobu context add ${trimmedName} --url <url>\` first.`);
    }
    config.currentContext = trimmedName;
    await saveContextConfig(config);
    return config;
}
function normalizeContextConfig(raw) {
    const contexts = {
        [DEFAULT_CONTEXT_NAME]: { url: DEFAULT_CONTEXT_URL },
    };
    for (const [name, value] of Object.entries(raw.contexts ?? {})) {
        const normalized = normalizeContextEntry(value);
        if (normalized)
            contexts[name] = normalized;
    }
    const currentContext = raw.currentContext && contexts[raw.currentContext]
        ? raw.currentContext
        : DEFAULT_CONTEXT_NAME;
    return {
        currentContext,
        contexts,
    };
}
function normalizeContextEntry(raw) {
    if (!raw || typeof raw !== "object")
        return undefined;
    const legacyServer = normalizeLegacyServerConfig(raw.server);
    const rawUrl = firstString(raw.url, raw.apiUrl);
    if (!rawUrl)
        return undefined;
    // Reject hand-edited / malformed stored URLs (e.g. "localhost:4111" with no
    // scheme) instead of letting them survive normalization. Every write path
    // already validates, so a stored entry that fails validation is corrupt —
    // dropping it keeps the invariant that resolveContext() returns a usable
    // endpoint.
    let normalizedUrl;
    try {
        normalizedUrl = normalizeAndValidateApiUrl(rawUrl);
    }
    catch {
        return undefined;
    }
    const entry = { url: normalizedUrl };
    const activeOrg = normalizeString(raw.activeOrg);
    if (activeOrg)
        entry.activeOrg = activeOrg;
    const memoryUrl = normalizeString(raw.memoryUrl);
    if (memoryUrl)
        entry.memoryUrl = memoryUrl;
    const lifecycle = normalizeLifecycle(raw.lifecycle) ?? legacyServer.lifecycle;
    if (lifecycle)
        entry.lifecycle = lifecycle;
    const cwd = normalizeString(raw.cwd) ?? legacyServer.cwd;
    if (cwd)
        entry.cwd = cwd;
    return entry;
}
function normalizeLegacyServerConfig(raw) {
    if (!raw || typeof raw !== "object")
        return {};
    const src = raw;
    const out = {};
    const cwd = normalizeString(src.cwd);
    if (cwd)
        out.cwd = cwd;
    const lifecycle = normalizeLifecycle(src.lifecycle);
    if (lifecycle)
        out.lifecycle = lifecycle;
    return out;
}
function normalizeLifecycle(value) {
    return value === "managed" || value === "external" ? value : undefined;
}
function normalizeString(value) {
    return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
function firstString(...values) {
    for (const value of values) {
        const normalized = normalizeString(value);
        if (normalized)
            return normalized;
    }
    return undefined;
}
export async function getServerConfig(contextName) {
    const config = await loadContextConfig();
    // Honor LOBU_CONTEXT the same way resolveContext() does — without this,
    // a caller that sets the env var to pin a context (e.g. the Mac menubar
    // spawning `lobu run` with LOBU_CONTEXT=local) gets the server settings
    // from `currentContext` instead.
    const name = contextName?.trim() ||
        process.env.LOBU_CONTEXT?.trim() ||
        config.currentContext;
    const context = config.contexts[name];
    if (!context || context.lifecycle !== "managed")
        return undefined;
    return deriveManagedServerConfig(context);
}
function deriveManagedServerConfig(context) {
    const out = { lifecycle: context.lifecycle };
    if (context.cwd)
        out.cwd = context.cwd;
    try {
        const parsed = new URL(context.url);
        const port = derivePort(parsed);
        if (port && Number.isInteger(port) && port > 0 && port <= 65535) {
            out.port = port;
        }
        const host = stripIpv6Brackets(parsed.hostname);
        if (host) {
            out.host = host;
        }
    }
    catch {
        // URL validation happens when contexts are saved/loaded; ignore here so a
        // hand-edited malformed config does not make the caller crash.
    }
    return Object.keys(out).length === 0 ? undefined : out;
}
/**
 * Resolve the effective port for a parsed context URL. An explicit `:port`
 * wins; otherwise fall back to the protocol default (80 for http, 443 for
 * https) so callers that bind from this struct don't drift from a context URL
 * like `http://localhost/api/v1` that already implies a port.
 */
function derivePort(parsed) {
    if (parsed.port)
        return Number.parseInt(parsed.port, 10);
    if (parsed.protocol === "http:")
        return 80;
    if (parsed.protocol === "https:")
        return 443;
    return undefined;
}
/**
 * `new URL("http://[::1]:8787").hostname` returns `[::1]` with brackets, which
 * Node's `server.listen({ host })` rejects. Strip them before exporting HOST.
 */
function stripIpv6Brackets(hostname) {
    return hostname.replace(/^\[|\]$/g, "");
}
function normalizeAndValidateApiUrl(apiUrl) {
    const normalized = normalizeApiUrl(apiUrl.trim());
    if (!normalized) {
        throw new Error("URL cannot be empty.");
    }
    try {
        const parsed = new URL(normalized);
        if (!parsed.protocol || !parsed.host) {
            throw new Error("Missing protocol or host");
        }
    }
    catch {
        throw new Error(`Invalid URL: ${apiUrl}`);
    }
    return normalized;
}
function normalizeApiUrl(url) {
    let end = url.length;
    while (end > 0 && url.charCodeAt(end - 1) === 47) {
        end--;
    }
    return end === url.length ? url : url.slice(0, end);
}
export async function findContextByUrl(apiUrl) {
    const config = await loadContextConfig();
    const normalizedSearch = normalizeApiUrl(apiUrl);
    for (const [name, context] of Object.entries(config.contexts)) {
        if (normalizeApiUrl(context.url) === normalizedSearch) {
            return contextToResolvedContext(name, context);
        }
    }
    return undefined;
}
export async function findContextByMemoryUrl(memoryUrl) {
    const config = await loadContextConfig();
    const normalizedSearch = normalizeMemoryBaseUrl(memoryUrl);
    for (const [name, context] of Object.entries(config.contexts)) {
        const candidate = normalizeMemoryBaseUrl(defaultMemoryUrlForContext(context));
        if (candidate === normalizedSearch) {
            return contextToResolvedContext(name, context);
        }
    }
    return undefined;
}
function defaultMemoryUrlForContext(context) {
    if (context?.memoryUrl)
        return context.memoryUrl;
    if (context && isLoopbackContextUrl(context.url)) {
        const url = new URL(context.url);
        url.pathname = "/mcp";
        url.search = "";
        url.hash = "";
        return url.toString().replace(/\/+$/, "");
    }
    return DEFAULT_LOBU_MCP_URL;
}
function isLoopbackContextUrl(input) {
    try {
        const { hostname } = new URL(input);
        return (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1");
    }
    catch {
        return false;
    }
}
function contextToResolvedContext(name, context) {
    return {
        name,
        url: normalizeApiUrl(context.url),
        source: name === DEFAULT_CONTEXT_NAME ? "default" : "config",
    };
}
function normalizeMemoryBaseUrl(input) {
    try {
        const url = new URL(input);
        url.hash = "";
        url.search = "";
        // Compare at the *base* MCP path (`/mcp`) so a context whose memoryUrl is
        // org-scoped (`/mcp/<slug>`) still matches a bare-base search and vice
        // versa — mirrors `baseMcpUrl()` in openclaw-auth.ts.
        url.pathname = "/mcp";
        return url.toString().replace(/\/+$/, "");
    }
    catch {
        return normalizeApiUrl(input);
    }
}
//# sourceMappingURL=context.js.map