import { existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { atomicWriteFileSync } from '../utils/atomic-write.js';
import { cliAuthBind, signCliAuth } from '../dashboard/auth.js';
/**
 * Classify a 404 from a `/__cli/*` request. A genuine "no token yet" only comes
 * from `/__cli/current` carrying `{ error: 'no_active_token' }`; everything else
 * means the port is answering for some other service (daemon IPC, a stray HTTP
 * server, …), not the dashboard rotate/current routes.
 */
export function classifyDashboard404(path, bodyText) {
    let body = null;
    try {
        body = JSON.parse(bodyText);
    }
    catch { /* non-JSON body → wrong service */ }
    const err = (body && typeof body === 'object') ? body.error : undefined;
    if (path === '/__cli/current' && err === 'no_active_token') {
        return { ok: false, reason: 'no-active-token' };
    }
    return {
        ok: false,
        reason: 'wrong-service',
        detail: bodyText ? `404 ${bodyText.slice(0, 200)}` : '404',
    };
}
/** Issue a single HMAC-authed request to one candidate port. */
export async function requestDashboardAt(opts) {
    const { host, port, path, secret } = opts;
    const fetchImpl = opts.fetchImpl ?? fetch;
    // Bind the credential to method + path + the port we're dialing. A malicious
    // server handed these headers during discovery therefore can't forward them
    // to a different `/__cli/*` route or to the real dashboard on another port —
    // the verifier reconstructs the bind from the port IT bound, so any forward
    // mismatches the signature (and the attacker can't re-sign without the secret).
    const { ts, nonce, sig } = signCliAuth(secret, cliAuthBind('POST', path, port));
    let res;
    try {
        res = await fetchImpl(`http://${host}:${port}${path}`, {
            method: 'POST',
            headers: {
                'X-Botmux-Cli-Ts': ts,
                'X-Botmux-Cli-Nonce': nonce,
                'X-Botmux-Cli-Auth': sig,
            },
        });
    }
    catch {
        return { ok: false, reason: 'unreachable' };
    }
    if (res.status === 404)
        return classifyDashboard404(path, await res.text().catch(() => ''));
    if (!res.ok) {
        return { ok: false, reason: 'http-error', detail: `${res.status} ${await res.text().catch(() => '')}` };
    }
    const body = await res.json().catch(() => ({}));
    if (!body.url)
        return { ok: false, reason: 'http-error', detail: 'malformed response (no url)' };
    return { ok: true, url: body.url };
}
/** A result that proves we actually reached the dashboard (vs. wrong port). */
function reachedDashboard(r) {
    return r.ok || (!r.ok && (r.reason === 'no-active-token' || r.reason === 'http-error'));
}
/**
 * Resolve the dashboard URL for `path`, trying the recorded port first and
 * self-healing the port file when it points at the wrong service.
 */
export async function callDashboard(opts) {
    const host = opts.host ?? '127.0.0.1';
    const probeSpan = opts.probeSpan ?? 20;
    const persistPort = opts.persistPort ?? true;
    const fetchImpl = opts.fetchImpl ?? fetch;
    const secretPath = join(opts.configDir, '.dashboard-secret');
    if (!existsSync(secretPath))
        return { ok: false, reason: 'no-secret' };
    const secret = readFileSync(secretPath, 'utf8').trim();
    const portFile = join(opts.configDir, '.dashboard-port');
    const recorded = (existsSync(portFile) ? readFileSync(portFile, 'utf8').trim() : '')
        || opts.envPort
        || String(opts.defaultPort);
    const candidate = Number(recorded);
    // 1. Try the recorded port. A success — or any state that proves we reached
    //    the dashboard (no-active-token / http-error) — is returned as-is.
    const first = await requestDashboardAt({ host, port: candidate, path: opts.path, secret, fetchImpl });
    if (reachedDashboard(first))
        return first;
    // 2. Only `wrong-service` warrants rediscovery: some server answered on the
    //    recorded port but it's not the dashboard, so the port file is stale.
    //    (`unreachable` during boot resolves by retrying the same port, not by
    //    scanning — so we leave it to the caller's retry loop.)
    if (first.ok || first.reason !== 'wrong-service')
        return first;
    const base = Number(opts.envPort || opts.defaultPort);
    for (let p = base; p <= base + probeSpan; p++) {
        if (p === candidate)
            continue;
        // Probe read-only (`/__cli/current`) so discovery never mints a token on a
        // server we're merely identifying. Only the real dashboard can answer the
        // HMAC-gated route as `ok` or `no-active-token`.
        const probe = await requestDashboardAt({ host, port: p, path: '/__cli/current', secret, fetchImpl });
        if (probe.ok || (!probe.ok && probe.reason === 'no-active-token')) {
            if (persistPort) {
                try {
                    atomicWriteFileSync(portFile, String(p));
                }
                catch { /* best-effort self-heal */ }
            }
            // Found the dashboard — perform the actually-requested op on its port.
            return requestDashboardAt({ host, port: p, path: opts.path, secret, fetchImpl });
        }
    }
    // No dashboard found in the probe range; surface the original wrong-service.
    return first;
}
//# sourceMappingURL=dashboard-endpoint.js.map