// ---------------------------------------------------------------------------
// github-fetch.ts — central, authenticated fetch helper for any vskill code
// path that talks to GitHub.
//
// Responsibilities:
//   1. Inject `Authorization: Bearer <token>` from the OS keychain when one is
//      available AND the URL targets an allow-listed GitHub host.
//   2. Stamp `User-Agent: vskill/<cli-version>` so GitHub's abuse heuristics
//      don't 403 our requests.
//   3. Enforce SSRF allowlist: only api.github.com + raw.githubusercontent.com.
//   4. Retry on 429 / Retry-After (capped at 30s, max 3 retries).
//   5. Surface 401 with an actionable message ("Run `vskill auth login`").
//   6. Refuse `/search/code` URLs entirely — that endpoint has a 10/min cap
//      and burns the whole installation budget if hit by accident.
//
// Designed for dependency injection (tokenProvider, fetchImpl, sleep) so tests
// never touch the real network or the OS keychain.
// ---------------------------------------------------------------------------
import { getDefaultKeychain } from "./keychain.js";
const ALLOWED_HOSTS = new Set([
    "api.github.com",
    "raw.githubusercontent.com",
]);
const MAX_RETRIES = 3;
const MAX_RETRY_AFTER_SECONDS = 30;
export class GitHubFetchError extends Error {
    status;
    body;
    constructor(status, body, message) {
        super(message);
        this.name = "GitHubFetchError";
        this.status = status;
        this.body = body;
    }
}
function defaultSleep(ms) {
    return new Promise((resolve) => setTimeout(resolve, ms));
}
export function resolveGitHubEnvToken(env = process.env) {
    return (env.VSKILL_TEST_GITHUB_PAT ||
        env.VSKILL_TEST_PRIVATE_GITHUB_PAT ||
        env.GITHUB_TOKEN ||
        env.GH_TOKEN ||
        null);
}
function resolveDefaultGitHubToken() {
    return getDefaultKeychain().getGitHubToken() || resolveGitHubEnvToken();
}
function isAllowedHost(url, allowed) {
    try {
        const u = new URL(url);
        if (u.protocol !== "https:")
            return false;
        return allowed.has(u.hostname);
    }
    catch {
        return false;
    }
}
function isSearchCode(url) {
    try {
        const u = new URL(url);
        return u.pathname.startsWith("/search/code");
    }
    catch {
        return false;
    }
}
function mergeHeaders(base, init) {
    const out = { ...base };
    if (!init?.headers)
        return out;
    if (init.headers instanceof Headers) {
        init.headers.forEach((v, k) => {
            out[k] = v;
        });
    }
    else if (Array.isArray(init.headers)) {
        for (const [k, v] of init.headers)
            out[k] = v;
    }
    else {
        for (const [k, v] of Object.entries(init.headers)) {
            out[k] = v;
        }
    }
    return out;
}
export function createGitHubFetch(opts = {}) {
    const tokenProvider = opts.tokenProvider ?? resolveDefaultGitHubToken;
    // Defer to globalThis.fetch on every call so test suites that replace
    // `globalThis.fetch = vi.fn(...)` continue to intercept requests.
    const fetchImpl = opts.fetchImpl ?? ((url, init) => globalThis.fetch(url, init));
    const sleep = opts.sleep ?? defaultSleep;
    const version = opts.version ?? "vskill";
    const allowed = opts.allowedHosts ?? ALLOWED_HOSTS;
    return async function githubFetch(url, init = {}) {
        if (isSearchCode(url)) {
            throw new Error("github-fetch: /search/code is not permitted (rate-limit guardrail; use a workflow-specific endpoint instead)");
        }
        if (!isAllowedHost(url, allowed)) {
            throw new Error(`github-fetch: host not allowed for SSRF guard (got ${safeHost(url)}; allowed: ${[...allowed].join(", ")})`);
        }
        const token = tokenProvider();
        const baseHeaders = {
            "User-Agent": `vskill/${version}`,
        };
        if (token)
            baseHeaders.Authorization = `Bearer ${token}`;
        const headers = mergeHeaders(baseHeaders, init);
        let attempt = 0;
        let lastResponse = null;
        while (attempt <= MAX_RETRIES) {
            const res = await fetchImpl(url, { ...init, headers });
            lastResponse = res;
            if (res.status === 429 || (res.status >= 500 && res.status < 600)) {
                const retryAfterRaw = res.headers.get("retry-after");
                const retryAfter = retryAfterRaw
                    ? Math.min(MAX_RETRY_AFTER_SECONDS, Math.max(0, Number(retryAfterRaw)))
                    : Math.min(MAX_RETRY_AFTER_SECONDS, 1 + attempt);
                attempt++;
                if (attempt > MAX_RETRIES)
                    break;
                await sleep(retryAfter * 1000);
                continue;
            }
            if (res.status === 401) {
                const body = await res.text().catch(() => "");
                throw new GitHubFetchError(401, body, token
                    ? "GitHub returned 401: token expired or insufficient scope. Run `vskill auth login` to re-authenticate."
                    : "GitHub returned 401: this resource requires authentication. Run `vskill auth login` to sign in.");
            }
            return res;
        }
        // Exhausted retries on 429/5xx — surface the last response.
        return lastResponse;
    };
}
function safeHost(url) {
    try {
        return new URL(url).hostname || "<invalid>";
    }
    catch {
        return "<invalid>";
    }
}
/**
 * Module-level default helper. Most call sites just want
 *   `await githubFetch(url)`
 * without juggling option objects.
 */
let _defaultFetch = null;
export function githubFetch(url, init) {
    if (!_defaultFetch)
        _defaultFetch = createGitHubFetch();
    return _defaultFetch(url, init);
}
/** Test-only reset hook. */
export function _resetDefaultGitHubFetchForTests() {
    _defaultFetch = null;
}
//# sourceMappingURL=github-fetch.js.map