/**
 * Per-project Linear issue counts for the `agents projects status` card.
 *
 * When a project definition carries `linear.projectId` (set via
 * `agents projects link <name> --linear`), the card shows one outcome line —
 * `12/30 done · 5 in progress` — counted from the Linear GraphQL API by state
 * TYPE (triage / backlog / unstarted / started / completed / canceled), never
 * hardcoded state names.
 *
 * The same fetch also yields the **next milestone** — the earliest-dated
 * milestone with unfinished issues — because each issue node carries its
 * `projectMilestone`. A percentage tells you how far along a project is; the
 * milestone tells you what it is due to hit next, which is the thing a person
 * actually plans around. Deriving it here costs no extra request.
 *
 * This is a best-effort card enrichment, not an explicit command: every failure
 * (no credential, offline, API error, timeout) degrades to `undefined` and the
 * card simply omits the line — never a hang, never a throw. `--no-remote`
 * skips it (it's network). The API key resolves through the same chain the rest
 * of the stack uses: $LINEAR_API_KEY → macOS Keychain (`resolveLinearApiKey`)
 * → the linear-cli config (`~/.linear-cli/config.json` `apiKey`).
 *
 * Paging is capped (10 × 250 issues) so a pathological project can't burn the
 * budget; a capped fetch reports `truncated: true` and the card renders the
 * total as a lower bound (`2500+ done`), never as the complete count.
 */
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { isRateLimited, noteRateLimited, parseRateLimitReset, readCached, writeCached, resolveLinearApiKey } from './linear-cache.js';
import { reserveLinearRequest } from './linear-rate-limit.js';
const LINEAR_API = 'https://api.linear.app/graphql';
/** Overall budget across all pages — the card must never hang on Linear. */
const TIMEOUT_MS = 8_000;
const PAGE_SIZE = 250;
/** Hard page cap so a pathological project can't page forever within the budget. */
const MAX_PAGES = 10;
/**
 * Pure mapping: a Linear issues response → card counts, grouping by state
 * type. Defensive at the boundary — a missing `issues`/`nodes` yields zeros,
 * an issue with no state still counts toward `total`.
 */
export function countsFromIssuesResponse(data) {
    const nodes = data.issues?.nodes ?? [];
    let done = 0;
    let inProgress = 0;
    for (const n of nodes) {
        const type = n?.state?.type;
        if (type === 'completed')
            done++;
        else if (type === 'started')
            inProgress++;
    }
    const counts = { done, total: nodes.length, inProgress };
    const declared = data.project?.projectMilestones?.nodes ?? [];
    const ordered = orderedMilestones(declared, nodes);
    if (ordered.length)
        counts.milestones = ordered;
    const next = nextMilestone(declared, nodes);
    if (next)
        counts.nextMilestone = next;
    return counts;
}
/**
 * Pick the milestone the project is working toward next.
 *
 * The **declared** list is authoritative for which milestones exist, their
 * names, and their dates; issues only supply progress. Deriving the list from
 * issues instead looks tempting (it costs no extra request) and is wrong: a
 * milestone with nothing filed under it yet would be invisible, and that is the
 * common case — every milestone in this repo's own Linear project has zero
 * issues assigned, so an issue-derived list showed nothing at all.
 *
 * Next = the earliest-dated milestone that is not finished. A milestone with no
 * issues counts as unfinished (it is upcoming work, not completed work). Undated
 * milestones sort last, ties break by declaration order, so the answer is stable.
 */
export function orderedMilestones(declared, nodes) {
    // Progress per milestone id, from whatever issues do carry one.
    const progress = new Map();
    for (const n of nodes) {
        const id = n?.projectMilestone?.id;
        if (!id)
            continue;
        const p = progress.get(id) ?? { done: 0, total: 0 };
        p.total++;
        if (n.state?.type === 'completed')
            p.done++;
        progress.set(id, p);
    }
    const all = declared
        .map((d, order) => {
        if (!d?.id || typeof d.name !== 'string' || !d.name)
            return undefined;
        const p = progress.get(d.id) ?? { done: 0, total: 0 };
        const m = { name: d.name, done: p.done, total: p.total, order };
        if (d.targetDate)
            m.targetDate = d.targetDate;
        if (d.status === 'next')
            m.isNext = true;
        return m;
    })
        .filter((m) => m !== undefined);
    // total 0 means "declared, nothing filed yet" — unfinished, not done.
    const open = (m) => m.total === 0 || m.done < m.total;
    all.sort((a, b) => {
        // Unfinished before finished: what is still ahead is what a reader is
        // scanning for.
        if (open(a) !== open(b))
            return open(a) ? -1 : 1;
        if (a.targetDate && b.targetDate)
            return a.targetDate < b.targetDate ? -1 : a.targetDate > b.targetDate ? 1 : a.order - b.order;
        if (a.targetDate)
            return -1;
        if (b.targetDate)
            return 1;
        return a.order - b.order;
    });
    return all.map(({ order: _order, ...m }) => m);
}
/**
 * The milestone the project is working toward next.
 *
 * Linear flags one itself (`status: "next"`), and that is the answer the user
 * sees in Linear's own UI, so it wins when present. Only when nothing is
 * flagged does this fall back to "earliest-dated unfinished", which is a
 * reasonable guess but still a guess.
 */
export function nextMilestone(declared, nodes) {
    const ordered = orderedMilestones(declared, nodes);
    const open = ordered.filter((m) => m.total === 0 || m.done < m.total);
    return open.find((m) => m.isNext) ?? open[0];
}
/** $LINEAR_API_KEY → macOS Keychain → ~/.linear-cli/config.json. Null if none. */
function resolveApiKey() {
    const fromChain = resolveLinearApiKey();
    if (fromChain)
        return fromChain;
    try {
        const cfg = JSON.parse(fs.readFileSync(path.join(os.homedir(), '.linear-cli', 'config.json'), 'utf8'));
        return cfg.apiKey?.trim() || null;
    }
    catch {
        return null;
    }
}
/**
 * Fetch issue counts for one Linear project, paging `issues` filtered by
 * project id. One shared AbortController bounds the WHOLE paged fetch at ~8s;
 * any failure (no key, network, API error, abort) returns undefined so the
 * card just omits the line. `fetchPage` is injectable for tests — the
 * accumulator (cursor hand-off, cap) is the risky logic, not the HTTP.
 */
export async function fetchLinearProjectCounts(projectId, fetchPage = fetchLinearIssuesPage, nowMs = Date.now()) {
    // Requests are the scarce budget (2500/hr; complexity is untouched), and this
    // pages up to 10 of them per project per call. Serve a fresh snapshot without
    // spending any.
    const cached = readCached(projectId, nowMs);
    if (cached && !cached.stale)
        return cached.value;
    // A prior 429 said there is nothing left to spend — don't spend one finding
    // that out again. Fall through to the stale snapshot rather than no line.
    if (isRateLimited(nowMs))
        return cached ? { ...cached.value, stale: true } : undefined;
    const ctrl = new AbortController();
    const timer = setTimeout(() => ctrl.abort(), TIMEOUT_MS);
    try {
        const all = [];
        // Declared on the project, not on its issues — only page 0 asks for it.
        let declared = [];
        let after;
        let truncated = false;
        for (let page = 0;; page++) {
            const data = await fetchPage(projectId, after, ctrl.signal);
            // A failed fetch keeps the last good answer on screen, marked stale,
            // instead of the line vanishing. One 8s timeout must not blank a chip
            // that was populated a minute ago — the rule `mergeAuthHealthEntries`
            // already encodes for account health.
            if (!data)
                return cached ? { ...cached.value, stale: true } : undefined;
            if (page === 0)
                declared = data.project?.projectMilestones?.nodes ?? [];
            all.push(...(data.issues?.nodes ?? []));
            const pi = data.issues?.pageInfo;
            if (!pi?.hasNextPage || !pi.endCursor)
                break;
            if (page + 1 >= MAX_PAGES) {
                // The cap cut the fetch short — total is a lower bound, say so.
                truncated = true;
                break;
            }
            after = pi.endCursor;
        }
        const counts = {
            ...countsFromIssuesResponse({
                issues: { nodes: all },
                project: { projectMilestones: { nodes: declared } },
            }),
            ...(truncated ? { truncated } : {}),
        };
        writeCached(projectId, counts, nowMs);
        return counts;
    }
    catch {
        return cached ? { ...cached.value, stale: true } : undefined;
    }
    finally {
        clearTimeout(timer);
    }
}
/** One real GraphQL page; undefined on any HTTP/API-level failure. */
async function fetchLinearIssuesPage(projectId, after, signal) {
    const apiKey = resolveApiKey();
    if (!apiKey)
        return undefined;
    // Proactive shared budget (PHNX-2310): every agent on this key spends from one
    // hourly pool, so N concurrent drains can't collectively blow Linear's 2500/hr
    // limit. When the pool is spent, skip the request — the accumulator serves the
    // last good (stale) snapshot instead, exactly as it does on any other failure.
    // This is the proactive complement to the reactive 429 backoff below.
    if (!reserveLinearRequest(apiKey))
        return undefined;
    // The declared-milestone list rides along on the FIRST page only — it does
    // not paginate, and re-requesting it per page would spend up to MAX_PAGES
    // copies of the same answer.
    const issuesSelection = 'issues(filter:{ project:{ id:{ eq:$p } } }, first:' +
        PAGE_SIZE +
        ', after:$after){ nodes{ state{ type } projectMilestone{ id } } pageInfo{ hasNextPage endCursor } }';
    const milestonesSelection = 'project(id:$pid){ projectMilestones(first:50){ nodes{ id name targetDate status } } }';
    const first = after === undefined;
    const res = await fetch(LINEAR_API, {
        method: 'POST',
        headers: { Authorization: apiKey, 'Content-Type': 'application/json' },
        body: JSON.stringify({
            query: first
                ? `query($p:ID!, $pid:String!, $after:String){ ${issuesSelection} ${milestonesSelection} }`
                : `query($p:ID!, $after:String){ ${issuesSelection} }`,
            variables: first
                ? { p: projectId, pid: projectId, after: null }
                : { p: projectId, after },
        }),
        signal,
    });
    if (res.status === 429) {
        // Record when the budget refills so later runs skip the call entirely
        // rather than spending one of the zero remaining requests to be told so.
        // The header is epoch milliseconds; absent or unparseable, back off a TTL.
        const now = Date.now();
        noteRateLimited(parseRateLimitReset(res.headers.get('x-ratelimit-requests-reset'), now), now);
        return undefined;
    }
    if (!res.ok)
        return undefined;
    const json = (await res.json());
    if (json.errors?.length || !json.data)
        return undefined;
    return json.data;
}
