/**
 * Paginated backfill of three GitHub REST streams with per-stream
 * high-water marks (HWMs):
 *   - /repos/{repo}/issues               -> issues_hwm
 *   - /repos/{repo}/issues/comments      -> issue_comments_hwm
 *   - /repos/{repo}/pulls/comments       -> pr_review_comments_hwm
 *
 * Crash safety (codex P1 #3): each stream's HWM is persisted ONLY after
 * the stream fully drains. If stream 2 throws mid-flight, stream 1's HWM
 * is committed and stream 2's stays NULL (or its prior value). Rerun
 * picks up from stream 1's saved HWM and re-fetches stream 2 from its
 * last committed point.
 *
 * Codex P1 #2: the /issues endpoint returns BOTH issues and PRs (a PR is
 * an issue with a `pull_request` field). We skip PRs here so they don't
 * get ingested under the issues schema. PRs are handled via webhook in
 * V1 (no /pulls backfill stream — review comments cover the discussion
 * surface).
 *
 * Privacy (V1 limitation): the REST list endpoints don't reliably set
 * `repository.private`, and resolving it requires an extra API call per
 * repo. Backfill leaves the field undefined so scopeFromRepository falls
 * through to private. Callers who know the repo is public can re-tag
 * downstream; the fail-safe default protects private orgs.
 */
import { openHippoDb, closeHippoDb } from '../../db.js';
import { ingestEvent } from './ingest.js';
const API = 'https://api.github.com';
function readCursor(root, tenantId, repo) {
    const db = openHippoDb(root);
    try {
        const row = db
            .prepare(`SELECT issues_hwm, issue_comments_hwm, pr_review_comments_hwm
         FROM github_cursors WHERE tenant_id = ? AND repo_full_name = ?`)
            .get(tenantId, repo);
        return {
            issues: row?.issues_hwm ?? null,
            issueComments: row?.issue_comments_hwm ?? null,
            prReviewComments: row?.pr_review_comments_hwm ?? null,
        };
    }
    finally {
        closeHippoDb(db);
    }
}
function writeOneHwm(root, tenantId, repo, column, value) {
    const db = openHippoDb(root);
    try {
        db.prepare(`INSERT INTO github_cursors (tenant_id, repo_full_name, ${column}, updated_at)
       VALUES (?, ?, ?, ?)
       ON CONFLICT(tenant_id, repo_full_name)
       DO UPDATE SET ${column} = excluded.${column}, updated_at = excluded.updated_at`).run(tenantId, repo, value, new Date().toISOString());
    }
    finally {
        closeHippoDb(db);
    }
}
/**
 * Build a synthetic `repository` object from the known repo full name.
 * REST list endpoints often omit the full repository object on each item;
 * we already know the repo since the caller passed it. `private` left
 * undefined so scopeFromRepository falls through to private.
 */
function syntheticRepository(repoFullName) {
    const [owner, name] = repoFullName.split('/');
    return {
        full_name: repoFullName,
        name: name ?? repoFullName,
        owner: { login: owner ?? repoFullName },
        // private intentionally omitted — fail-safe to private scope.
    };
}
/**
 * Parse the trailing issue/PR number from a REST API URL.
 * e.g. https://api.github.com/repos/o/r/issues/42 -> 42
 *      https://api.github.com/repos/o/r/pulls/7   -> 7
 */
function parseTrailingNumber(url) {
    if (!url)
        return null;
    const m = url.match(/\/(\d+)(?:\?.*)?$/);
    if (!m)
        return null;
    const n = Number(m[1]);
    return Number.isFinite(n) ? n : null;
}
function isIssuesItem(x) {
    if (!x || typeof x !== 'object')
        return false;
    const o = x;
    if (typeof o.number !== 'number')
        return false;
    if (typeof o.title !== 'string')
        return false;
    const u = o.user;
    if (!u || typeof u.login !== 'string' || typeof u.id !== 'number')
        return false;
    return true;
}
function isCommentItem(x) {
    if (!x || typeof x !== 'object')
        return false;
    const o = x;
    if (typeof o.id !== 'number')
        return false;
    const u = o.user;
    if (!u || typeof u.login !== 'string' || typeof u.id !== 'number')
        return false;
    return true;
}
/**
 * Drain one stream end-to-end. Pauses and retries on rate-limit. Throws on
 * any other fetch error so the caller leaves the HWM unchanged (round 1
 * codex P1 #3 crash safety).
 *
 * v1.3.1 (round 2 codex P1s + claude P1):
 *   - Tracks max(updated_at) across ALL fetched items, including ones
 *     `toIngestEvent` rejects (e.g., PRs returned via `/issues`). Otherwise
 *     a page of pure PRs would never advance the HWM and the next run would
 *     re-fetch the same window forever.
 *   - Returns `drained: boolean` — true only when the stream actually ran
 *     to next=null. Callers MUST NOT advance the HWM when drained=false.
 *     Was a bug in v1.3.0: hitting maxPerStream cap returned the partial
 *     maxUpdatedAt and the caller persisted it, skipping the unfetched tail.
 */
async function drainStream(ctx, url0, toIngestEvent, fetcher, token, sleep, maxItems) {
    let url = url0;
    let ingested = 0;
    let pages = 0;
    let maxUpdatedAt = null;
    while (url) {
        // Fetch with rate-limit retry loop.
        let page;
        while (true) {
            page = await fetcher({ url, token });
            if (page.rateLimit.reason !== 'none') {
                await sleep(page.rateLimit.sleepSeconds * 1000);
                continue;
            }
            break;
        }
        pages++;
        for (const item of page.items) {
            // v1.3.1: track updated_at on EVERY item, before the toIngestEvent
            // filter. Skipped PRs from /issues still contribute to the HWM so
            // PR-only pages don't loop forever.
            const updatedAt = item && typeof item === 'object'
                ? (item.updated_at ?? null)
                : null;
            if (updatedAt && (!maxUpdatedAt || updatedAt > maxUpdatedAt)) {
                maxUpdatedAt = updatedAt;
            }
            const evt = toIngestEvent(item);
            if (!evt)
                continue;
            const r = ingestEvent(ctx, {
                event: evt,
                rawBody: JSON.stringify(item),
                deliveryId: `backfill:${ctx.tenantId}:${updatedAt ?? ''}`,
            });
            if (r.status === 'ingested' || r.status === 'skipped')
                ingested++;
            if (maxItems && ingested >= maxItems) {
                // Capped mid-stream: caller MUST NOT advance the HWM (drained=false).
                return { ingested, pages, maxUpdatedAt, drained: false };
            }
        }
        url = page.next;
    }
    return { ingested, pages, maxUpdatedAt, drained: true };
}
export async function backfillRepo(ctx, opts) {
    const sleep = opts.sleepMs ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
    const cursors = readCursor(ctx.hippoRoot, ctx.tenantId, opts.repoFullName);
    const repository = syntheticRepository(opts.repoFullName);
    const result = {
        ingested: { issues: 0, issueComments: 0, prReviewComments: 0 },
        pages: { issues: 0, issueComments: 0, prReviewComments: 0 },
    };
    // ---------- Stream 1: issues (skip PRs) ----------
    const issuesUrl = `${API}/repos/${opts.repoFullName}/issues?state=all&per_page=100` +
        (cursors.issues ? `&since=${encodeURIComponent(cursors.issues)}` : '');
    const issuesRes = await drainStream(ctx, issuesUrl, (item) => {
        if (!isIssuesItem(item))
            return null;
        // Codex P1 #2: /issues returns PRs too — skip them.
        if (item.pull_request)
            return null;
        const payload = {
            action: 'opened',
            repository,
            issue: {
                number: item.number,
                title: item.title,
                body: item.body ?? null,
                user: { login: item.user.login, id: item.user.id },
                updated_at: item.updated_at,
            },
        };
        return { eventName: 'issues', payload };
    }, opts.fetcher, opts.token, sleep, opts.maxPerStream);
    result.ingested.issues = issuesRes.ingested;
    result.pages.issues = issuesRes.pages;
    // v1.3.1: only advance HWM when the stream actually drained. A capped run
    // (--max) must leave the HWM at its previous value so the next invocation
    // re-fetches the unprocessed tail.
    if (issuesRes.drained && issuesRes.maxUpdatedAt) {
        writeOneHwm(ctx.hippoRoot, ctx.tenantId, opts.repoFullName, 'issues_hwm', issuesRes.maxUpdatedAt);
    }
    // ---------- Stream 2: repo-level issue comments ----------
    const commentsUrl = `${API}/repos/${opts.repoFullName}/issues/comments?per_page=100` +
        (cursors.issueComments
            ? `&since=${encodeURIComponent(cursors.issueComments)}`
            : '');
    const commentsRes = await drainStream(ctx, commentsUrl, (item) => {
        if (!isCommentItem(item))
            return null;
        const c = item;
        const issueNumber = parseTrailingNumber(c.issue_url);
        if (issueNumber === null)
            return null;
        const payload = {
            action: 'created',
            repository,
            issue: { number: issueNumber },
            comment: {
                id: c.id,
                body: c.body ?? null,
                user: { login: c.user.login, id: c.user.id },
                updated_at: c.updated_at,
            },
        };
        return { eventName: 'issue_comment', payload };
    }, opts.fetcher, opts.token, sleep, opts.maxPerStream);
    result.ingested.issueComments = commentsRes.ingested;
    result.pages.issueComments = commentsRes.pages;
    if (commentsRes.drained && commentsRes.maxUpdatedAt) {
        writeOneHwm(ctx.hippoRoot, ctx.tenantId, opts.repoFullName, 'issue_comments_hwm', commentsRes.maxUpdatedAt);
    }
    // ---------- Stream 3: repo-level PR review comments ----------
    const prCommentsUrl = `${API}/repos/${opts.repoFullName}/pulls/comments?per_page=100` +
        (cursors.prReviewComments
            ? `&since=${encodeURIComponent(cursors.prReviewComments)}`
            : '');
    const prCommentsRes = await drainStream(ctx, prCommentsUrl, (item) => {
        if (!isCommentItem(item))
            return null;
        const c = item;
        const prNumber = parseTrailingNumber(c.pull_request_url);
        if (prNumber === null)
            return null;
        const payload = {
            action: 'created',
            repository,
            pull_request: { number: prNumber },
            comment: {
                id: c.id,
                body: c.body ?? null,
                user: { login: c.user.login, id: c.user.id },
                updated_at: c.updated_at,
            },
        };
        return { eventName: 'pull_request_review_comment', payload };
    }, opts.fetcher, opts.token, sleep, opts.maxPerStream);
    result.ingested.prReviewComments = prCommentsRes.ingested;
    result.pages.prReviewComments = prCommentsRes.pages;
    if (prCommentsRes.drained && prCommentsRes.maxUpdatedAt) {
        writeOneHwm(ctx.hippoRoot, ctx.tenantId, opts.repoFullName, 'pr_review_comments_hwm', prCommentsRes.maxUpdatedAt);
    }
    return result;
}
//# sourceMappingURL=backfill.js.map