import http from "node:http";
import { createJwksTokenVerifier } from "mcp-oauth";
import { createTestMcpServer, nodeFetch, TokenVerificationError } from "tiny-http-mcp-server";
import { createOAuthTestServer, } from "tiny-oauth-test-server";
import { hasOwnErrorCode } from "./error-codes.js";
const PROTECTED_RESOURCE_METADATA_PATH = "/.well-known/oauth-protected-resource";
function getProtectedResourceMetadataUrl(mcpUrl) {
    const url = new URL(mcpUrl);
    const protectedResourcePath = url.pathname === "/" ? "" : url.pathname.length > 1 && url.pathname.endsWith("/")
        ? url.pathname.slice(0, -1)
        : url.pathname;
    return new URL(`${PROTECTED_RESOURCE_METADATA_PATH}${protectedResourcePath}`, url).toString();
}
function normalizePath(path) {
    if (path === undefined || path.length === 0) {
        return "/mcp";
    }
    if (path.includes("?") || path.includes("#")) {
        throw new Error("mcpPath must not include a query or fragment");
    }
    const normalizedPath = path.startsWith("/") ? path : `/${path}`;
    return normalizedPath.length > 1 && normalizedPath.endsWith("/")
        ? normalizedPath.slice(0, -1)
        : normalizedPath;
}
function parseHttpUrl(value, label) {
    let url;
    try {
        url = new URL(value);
    }
    catch {
        throw new Error(`${label} must be an absolute URL`);
    }
    if (url.protocol !== "http:") {
        throw new Error(`${label} must use http: because the embedded servers do not terminate TLS`);
    }
    if (url.pathname === "/" || url.pathname.length === 0) {
        throw new Error(`${label} must include a non-root path such as /oauth so OAuth metadata discovery stays unambiguous`);
    }
    if (url.search.length > 0 || url.hash.length > 0) {
        throw new Error(`${label} must not include a query or fragment`);
    }
    return url;
}
function parseResourceUrl(value) {
    let url;
    try {
        url = new URL(value);
    }
    catch {
        throw new Error("resource must be an absolute URL");
    }
    if (url.hash.length > 0) {
        throw new Error("resource must not include a fragment");
    }
    return url.toString();
}
function normalizeHostForListen(hostname) {
    if (hostname.startsWith("[") && hostname.endsWith("]")) {
        return hostname.slice(1, -1);
    }
    return hostname;
}
function formatHostnameForUrl(hostname) {
    if (hostname.includes(":") && !hostname.startsWith("[")) {
        return `[${hostname}]`;
    }
    return hostname;
}
function buildUrl(hostname, port, path) {
    const url = new URL("http://127.0.0.1");
    url.hostname = formatHostnameForUrl(hostname);
    url.port = String(port);
    url.pathname = path;
    url.search = "";
    url.hash = "";
    return url.toString();
}
function isRevokedToken(oauth, token) {
    const candidate = oauth;
    return candidate.isTokenRevoked?.(token) ?? false;
}
function normalizeScopes(scopes) {
    const normalizedScopes = scopes === undefined ? ["mcp.read"] : [...scopes];
    if (normalizedScopes.some((scope) => scope.trim().length === 0)) {
        throw new Error("scopes must contain non-empty values");
    }
    if (normalizedScopes.some((scope) => scope.trim() !== scope || scope.includes(" "))) {
        throw new Error("scope entries must not contain spaces");
    }
    return normalizedScopes;
}
function normalizeTtlSeconds(ttlSeconds) {
    const normalizedTtlSeconds = ttlSeconds ?? 60;
    if (!Number.isInteger(normalizedTtlSeconds) || normalizedTtlSeconds <= 0) {
        throw new TypeError(`ttlSeconds must be a positive integer, received ${normalizedTtlSeconds}`);
    }
    return normalizedTtlSeconds;
}
function normalizeListenPort(port) {
    const normalizedPort = port ?? 0;
    if (!Number.isInteger(normalizedPort) || normalizedPort < 0 || normalizedPort > 65535) {
        throw new Error("port must be an integer between 0 and 65535");
    }
    return normalizedPort;
}
function closeServer(server) {
    return new Promise((resolve, reject) => {
        server.close((error) => {
            if (error !== undefined) {
                reject(error);
                return;
            }
            resolve();
        });
        server.closeIdleConnections?.();
        server.closeAllConnections?.();
    });
}
async function reservePort(hostname) {
    const server = http.createServer();
    await new Promise((resolve, reject) => {
        server.once("error", reject);
        server.listen(0, hostname, () => resolve());
    });
    const address = server.address();
    if (address === null || typeof address === "string") {
        await closeServer(server);
        throw new Error("Expected temporary port reservation to bind to a TCP port");
    }
    const port = address.port;
    await closeServer(server);
    return port;
}
export function createMcpOAuthTestServer(options = {}) {
    const mcpPath = normalizePath(options.mcpPath);
    const scopes = normalizeScopes(options.scopes);
    const ttlSeconds = normalizeTtlSeconds(options.ttlSeconds);
    const configuredIssuer = options.issuer === undefined ? undefined : parseHttpUrl(options.issuer, "issuer");
    const configuredResource = options.resource === undefined ? undefined : parseResourceUrl(options.resource);
    let currentHandle = null;
    let listenPending = false;
    return {
        async listen(listenOptions = {}) {
            if (currentHandle !== null || listenPending) {
                throw new Error("MCP OAuth test server is already listening");
            }
            const hostname = listenOptions.hostname ?? "127.0.0.1";
            const requestedPort = normalizeListenPort(listenOptions.port);
            listenPending = true;
            let lastError;
            for (let attempt = 0; attempt < 10; attempt += 1) {
                try {
                    const handle = await listenOnce(hostname, requestedPort);
                    listenPending = false;
                    return handle;
                }
                catch (error) {
                    lastError = error;
                    if (requestedPort !== 0 || !isAddressInUseError(error)) {
                        listenPending = false;
                        throw error;
                    }
                }
            }
            listenPending = false;
            throw lastError;
        },
    };
    async function listenOnce(hostname, requestedPort) {
        const oauthHostname = configuredIssuer === undefined
            ? hostname
            : normalizeHostForListen(configuredIssuer.hostname);
        const oauthPort = configuredIssuer === undefined
            ? await reservePort(oauthHostname)
            : Number(configuredIssuer.port.length > 0 ? configuredIssuer.port : "80");
        const issuer = configuredIssuer?.toString() ?? buildUrl(oauthHostname, oauthPort, "/oauth");
        let fixedPort = requestedPort;
        if (fixedPort !== 0 &&
            fixedPort === oauthPort &&
            normalizeHostForListen(hostname) === oauthHostname) {
            throw new Error("issuer must not use the same hostname and port as the MCP listener because this fixture runs them on separate HTTP listeners");
        }
        if (fixedPort === 0 && configuredResource === undefined) {
            do {
                fixedPort = await reservePort(hostname);
            } while (fixedPort === oauthPort &&
                normalizeHostForListen(hostname) === oauthHostname);
        }
        const oauth = createOAuthTestServer({
            issuer,
            defaultTokenTtlSeconds: ttlSeconds,
            staticClients: options.staticClients,
            defaultAuthorization: {
                autoApprove: options.autoApprove ?? false,
                scopes,
            },
        });
        let oauthHandle;
        let mcpHandle;
        try {
            oauthHandle = await oauth.listen({
                port: oauthPort,
                hostname: oauthHostname,
            });
            const resource = configuredResource ?? buildUrl(hostname, fixedPort, mcpPath);
            const jwksVerifier = createJwksTokenVerifier({
                jwksUrl: `${oauth.issuer}/.well-known/jwks.json`,
                fetch: (input, init) => nodeFetch(input instanceof Request ? input.url : input, init),
            });
            const verifier = {
                async verify(input) {
                    if (isRevokedToken(oauth, input.token)) {
                        throw new TokenVerificationError({
                            error: "invalid_token",
                            errorDescription: "token revoked",
                        });
                    }
                    return jwksVerifier.verify(input);
                },
            };
            mcpHandle = await createTestMcpServer({
                enableJsonResponse: true,
                oauth: {
                    resource,
                    authorizationServers: [oauth.issuer],
                    bearerMethodsSupported: ["header"],
                    scopesSupported: scopes,
                    requiredScopes: scopes,
                    verifier,
                },
            }).listenHttp({
                port: fixedPort,
                hostname,
                path: mcpPath,
            });
            const prmUrl = getProtectedResourceMetadataUrl(mcpHandle.url);
            const handle = {
                url: mcpHandle.url,
                mcpUrl: mcpHandle.url,
                prmUrl,
                resource,
                oauth,
                close: async () => {
                    if (currentHandle !== handle) {
                        return;
                    }
                    const results = await Promise.allSettled([
                        mcpHandle?.close(),
                        oauthHandle?.close(),
                    ]);
                    const rejected = results.find((result) => result.status === "rejected");
                    if (rejected !== undefined) {
                        throw rejected.reason;
                    }
                    currentHandle = null;
                },
            };
            currentHandle = handle;
            return handle;
        }
        catch (error) {
            const closeOperations = oauthHandle === undefined ? [] : [oauthHandle.close()];
            if (mcpHandle !== undefined) {
                closeOperations.unshift(mcpHandle.close());
            }
            const closeResults = await Promise.allSettled(closeOperations);
            const failedClose = closeResults.find((result) => result.status === "rejected");
            if (failedClose !== undefined) {
                throw failedClose.reason;
            }
            throw error;
        }
    }
}
function isAddressInUseError(error) {
    return hasOwnErrorCode(error, "EADDRINUSE");
}
