/**
 * Reusable device discovery.
 *
 * `agents devices sync` was the only thing that ever populated the registry,
 * and it was purely user-invoked — so the registry sat empty until someone
 * remembered to run it. This module extracts the ingest so it can be triggered
 * automatically (from `agents sync` and `agents setup`) without duplicating the
 * tailscale-parse-and-upsert loop, and exposes the pure pending-device diff the
 * curation picker and the menu-bar probe both need.
 *
 * Two failure modes, one function:
 *   - hard (default): the CLI `agents devices sync` action wants a clear error
 *     and a non-zero exit when tailscale is missing.
 *   - soft (`soft: true`): auto-callers must never abort setup/sync because a
 *     machine has no tailscale — they get a result with `ok: false` instead.
 */
import * as os from 'os';
import { loadDevices, loadIgnored, upsertDevice, } from './registry.js';
import { nodeToDeviceInput, parseTailscaleStatus, tailscaleStatusJson, } from './tailscale.js';
/**
 * The login user to stamp onto newly-synced devices. Tailscale status carries a
 * node's OS and address but NOT the account you ssh in as, so we materialize the
 * local operator's username — tailnet devices are overwhelmingly one person's
 * boxes, and this is exactly the account ssh would already fall back to. Pinning
 * it in the registry makes `--device <device>` dial that account no matter which
 * machine launches the fan-out (a peer whose local user differs otherwise dials
 * the wrong account). Returns undefined when the username isn't a safe ssh
 * identifier, so a weird value never lands in the registry. */
export function localLoginUser() {
    let u;
    try {
        u = os.userInfo().username;
    }
    catch {
        u = process.env.USER || process.env.USERNAME || undefined;
    }
    return sanitizeLoginUser(u);
}
/**
 * Reduce a raw OS username to a safe ssh account, or undefined. Windows reports
 * the login as `COMPUTER\user` / `DOMAIN\user`; the ssh account is the bare name
 * after the backslash — without this strip the `\` fails the charset guard and
 * Windows boxes never pin a user. Pure, so the platform-specific munging is
 * unit-tested without reading the real OS user. */
export function sanitizeLoginUser(raw) {
    if (!raw)
        return undefined;
    const bare = raw.includes('\\') ? raw.slice(raw.lastIndexOf('\\') + 1) : raw;
    return /^[a-zA-Z0-9._-]+$/.test(bare) ? bare : undefined;
}
/**
 * Fill in a device's login user during sync WITHOUT ever clobbering an account
 * the user pinned. Precedence: an existing registered user wins; else the local
 * operator's username; else leave it unset (ssh's implicit local default still
 * applies). Pure so the "never overwrite an explicit user" guard is unit-tested
 * without a tailnet. */
export function withDefaultUser(input, prevUser, localUser) {
    if (input.user || prevUser || !localUser)
        return input;
    return { ...input, user: localUser };
}
/**
 * The nodes automatic discovery is allowed to see: sharee nodes (shared INTO
 * the tailnet by another user, e.g. a funnel ingress relay) are not the
 * operator's machines, so they must never be bootstrap-registered nor surfaced
 * as pending. Explicit paths (`devices register <name>`, `devices add`, the
 * `fleet:` manifest bootstrap) are deliberate user actions and stay unfiltered.
 * Pure so the policy is unit-testable without a tailnet.
 */
export function discoverableNodes(nodes) {
    return nodes.filter((n) => !n.sharee);
}
/**
 * Default checked-state for a node in the interactive sync picker. Pressing
 * Enter registers every checked node, so "checked" must mean "auto-sync would
 * register this": not dismissed, and not a sharee node — unless the user
 * already deliberately registered that sharee node, in which case Enter must
 * keep the fleet as-is. Pure so the default matrix is unit-testable without a
 * prompt.
 */
export function defaultPickerChecked(node, registered, ignored) {
    return !ignored.has(node.name) && (!node.sharee || registered.has(node.name));
}
/**
 * Node names present on the tailnet but neither already in the registry nor on
 * the ignore-list — i.e. genuinely new devices worth surfacing. Pure so the
 * flag matrix is unit-testable without a live tailnet.
 */
export function computePendingDevices(nodes, registered, ignored) {
    const known = new Set(registered);
    const skip = new Set(ignored);
    return nodes
        .map((n) => n.name)
        .filter((name) => !known.has(name) && !skip.has(name));
}
/**
 * Which discovered nodes to upsert this run — the mode-defining decision, pure
 * so it is unit-testable without a tailnet. Ignored nodes are always skipped.
 * In `refresh` mode a node that isn't already registered is skipped too (it
 * stays pending for approval); `bootstrap` includes every non-ignored node.
 */
export function selectNodesToUpsert(nodes, registered, ignored, mode) {
    return nodes.filter((n) => {
        if (ignored.has(n.name))
            return false;
        if (mode === 'refresh' && !registered.has(n.name))
            return false;
        return true;
    });
}
/**
 * Ingest `tailscale status --json` into the registry. In soft mode a missing
 * tailscale binary / unreachable daemon resolves to `{ ok: false }` instead of
 * throwing, so callers wiring this into setup/sync never abort the whole run.
 * The `pending` list is computed against the registry state BEFORE this sync so
 * "new" means "not previously registered and not ignored".
 */
export async function runDeviceSync(opts = {}) {
    const mode = opts.mode ?? 'bootstrap';
    // Soft mode must be non-fatal for ANY failure, not just a missing tailscale:
    // a corrupted registry/ignore file (both throw by design), a disk error, or
    // registry lock contention (plausible when many agents SessionStart-autosync
    // the same host at once) would otherwise abort the whole `agents sync`. The
    // whole body is inside the guard so the "never a sync failure" promise holds.
    try {
        const nodes = discoverableNodes(parseTailscaleStatus(tailscaleStatusJson()));
        const [registeredBefore, ignored] = await Promise.all([loadDevices(), loadIgnored()]);
        const registered = new Set(Object.keys(registeredBefore));
        const pendingNames = computePendingDevices(nodes, registered, ignored);
        const byName = new Map(nodes.map((n) => [n.name, n]));
        const pending = pendingNames.map((name) => ({
            name,
            platform: byName.get(name)?.platform ?? 'unknown',
        }));
        const toUpsert = selectNodesToUpsert(nodes, registered, ignored, mode);
        const localUser = localLoginUser();
        for (const node of toUpsert) {
            const input = withDefaultUser(nodeToDeviceInput(node), registeredBefore[node.name]?.user, localUser);
            await upsertDevice(node.name, input);
        }
        return { ok: true, synced: toUpsert.length, syncedNames: toUpsert.map((node) => node.name), pending };
    }
    catch (err) {
        if (opts.soft) {
            return { ok: false, synced: 0, syncedNames: [], pending: [], reason: err?.message ?? String(err) };
        }
        throw err;
    }
}
export function partitionWantedDevices(wanted, registered, tailscaleNames, ignored) {
    const out = { toRegister: [], unresolved: [] };
    for (const name of wanted) {
        if (registered.has(name))
            continue;
        if (tailscaleNames.has(name) && !ignored.has(name))
            out.toRegister.push(name);
        else
            out.unresolved.push(name);
    }
    return out;
}
/**
 * Fresh-machine bootstrap for `agents apply`: given the device names a `fleet:`
 * manifest wants, register any that aren't in the local registry by resolving
 * them LIVE from Tailscale — so a freshly-cloned `agents.yaml` (which carries
 * names only, never IPs/usernames) reconstructs its roster with zero committed
 * connection details. Soft by design: a missing tailscale binary or an offline
 * name yields `unresolved` rather than throwing, so `apply` degrades to "these
 * devices aren't reachable yet" instead of aborting. Reuses the same
 * parse→withDefaultUser→upsert path as `runDeviceSync`, so a bootstrapped device
 * is identical to a synced one.
 */
export async function ensureDevicesRegistered(wantedNames) {
    const registryBefore = await loadDevices();
    const registered = new Set(Object.keys(registryBefore));
    const missing = wantedNames.filter((n) => !registered.has(n));
    if (missing.length === 0)
        return { registered: [], unresolved: [] };
    let nodes;
    try {
        nodes = parseTailscaleStatus(tailscaleStatusJson());
    }
    catch {
        // Tailscale absent/unreachable — nothing resolvable; report all missing.
        return { registered: [], unresolved: missing };
    }
    const ignored = await loadIgnored();
    const tailscaleNames = new Set(nodes.map((n) => n.name));
    const { toRegister, unresolved } = partitionWantedDevices(missing, registered, tailscaleNames, ignored);
    const byName = new Map(nodes.map((n) => [n.name, n]));
    const localUser = localLoginUser();
    const done = [];
    for (const name of toRegister) {
        const input = withDefaultUser(nodeToDeviceInput(byName.get(name)), registryBefore[name]?.user, localUser);
        await upsertDevice(name, input);
        done.push(name);
    }
    return { registered: done, unresolved };
}
export function planDeviceReconciliation(allNames, keep, registered, ignored) {
    const keepSet = new Set(keep);
    const regSet = new Set(registered);
    const ignSet = new Set(ignored);
    const out = { toRegister: [], toUnignore: [], toRemove: [], toIgnore: [] };
    for (const name of allNames) {
        if (keepSet.has(name)) {
            out.toRegister.push(name);
            if (ignSet.has(name))
                out.toUnignore.push(name);
        }
        else {
            if (regSet.has(name))
                out.toRemove.push(name);
            out.toIgnore.push(name);
        }
    }
    return out;
}
