/**
 * Device registry.
 *
 * Manages the persistent registry of SSH device profiles stored at
 * ~/.agents/.history/devices/registry.json. Each profile records what we had
 * to re-derive by hand the first time we reached a host: its platform (so we
 * know PowerShell vs POSIX), the login user, how to address it (Tailscale
 * DNS name / IP), and how to authenticate (pubkey, or a password pulled from
 * a secrets bundle).
 *
 * Like the team registry this is per-machine runtime state (it embeds a host
 * list + addresses) and lives under .history/ so it is NOT pulled in by
 * `agents repo push`. The load/save/lock plumbing is a deliberate clone of
 * src/lib/teams/registry.ts so the data-loss guarantees match exactly.
 */
import * as fs from 'fs/promises';
import * as fsSync from 'fs';
import * as path from 'path';
import lockfile from 'proper-lockfile';
import { getDevicesRegistryPath, readMeta, updateMeta } from '../state.js';
import { atomicWriteJsonSync } from '../fs-atomic.js';
import { machineId } from '../machine-id.js';
import { addIgnoredEntry, unionDeviceIgnored } from './device-docs.js';
import { logAndContinueOnLockCompromised } from '../lock-compromise.js';
import { removeStatsCacheEntry } from './stats-cache.js';
/**
 * Whether a fan-out should dial this device, honouring the preference stated on
 * {@link DeviceProfile.reachability}: the live SSH probe wins over the cached
 * {@link DeviceTailscale.online} snapshot.
 *
 * Reading only `tailscale.online` is wrong in both directions, and both were
 * live on a real fleet. A `via:"manual"` device never gets a tailscale peer
 * entry at all, so its `online` is permanently `undefined` and a strict
 * `=== true` test skipped it forever — every session on that box was invisible
 * to the cross-fleet sweep. Conversely a box that has since gone to sleep keeps
 * a stale `online:true` and gets dialed, burning a full ConnectTimeout and
 * reporting a false "unreachable" that callers treat as doubt.
 */
export function isDialableDevice(d) {
    // Union, deliberately: either signal saying "go" is enough. A probe may only
    // ADD a peer to the sweep, never remove one.
    //
    // The probe is not trustworthy enough to exclude on. It runs with a short SSH
    // budget, so on a congested tailnet it returns false negatives — observed
    // marking the LOCAL machine unreachable, and flipping a live worker box from
    // reachable to unreachable nine minutes apart. Letting that shrink the sweep
    // would hide sessions on healthy boxes, a worse failure than the one below.
    //
    // The snapshot alone is not enough either: a device registered with
    // `address.via: "manual"` never gets a tailscale peer entry, so `online`
    // stays undefined and a strict `=== true` test skipped it forever, making
    // every session on that box unresolvable from any other machine.
    //
    // So: no tailscale block at all is unknown-not-offline (the rule `ssh.ts`
    // renderDeviceTable and the ext's `isDeviceOnline` already use, so the picker
    // and the sweep agree on who exists), and a positive probe rescues a device
    // whose snapshot says offline. The cost of dialing a box that is actually
    // asleep is one ConnectTimeout — the pre-existing behaviour, not a regression.
    if (d.reachability?.reachable)
        return true;
    return !d.tailscale || d.tailscale.online === true;
}
function registryPath() {
    return getDevicesRegistryPath();
}
/** Valid logical device name: the ssh-alias charset, so it renders into an
 * unambiguous `Host` stanza and is safe as an ssh target. */
const DEVICE_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
/**
 * `--device` values that mean "resolve me", not "connect to a box with this
 * name". A real device registered under one of these would be unreachable,
 * and pinning `interactive.host` to one is a misconfiguration that has to fail
 * at WRITE time — otherwise the read side can only report "none is set", which
 * tells the user to run the command they just ran.
 */
export const RESERVED_DEVICE_NAMES = new Set(['auto', 'interactive', 'all']);
/**
 * Throw if `name` is not usable as an ssh alias (no spaces, quotes, etc.).
 *
 * SHAPE ONLY. Safe on read paths, which must keep working for a name that is
 * already registered — including one this version would refuse to create.
 */
export function assertValidDeviceName(name) {
    if (!DEVICE_NAME_RE.test(name)) {
        throw new Error(`Invalid device name ${JSON.stringify(name)}. Use letters, digits, '.', '_', '-' (no spaces) — e.g. 'win-mini'.`);
    }
}
/**
 * Throw if `name` cannot be used for a NEW device: bad shape, or a reserved
 * routing sentinel.
 *
 * Deliberately separate from {@link assertValidDeviceName}, and called from
 * exactly two kinds of place: `agents devices add <name>`, and the config keys
 * that point AT a device (`interactive.host`).
 *
 * Not from `upsertDevice`, `addIgnored` or the discovery writers. Those all
 * receive tailnet node names the user never typed — `devices sync` upserts every
 * observed node in a loop with no per-node catch, so one node named `auto` would
 * abort the entire sync and register nothing after it. A name the fleet OBSERVES
 * is not a name anyone CHOSE; only the second kind is policy's business.
 */
export function assertRegistrableDeviceName(name) {
    assertValidDeviceName(name);
    if (RESERVED_DEVICE_NAMES.has(name.trim().toLowerCase())) {
        throw new Error(`${JSON.stringify(name)} is a reserved --device value, not a device name. ` +
            `Reserved: ${[...RESERVED_DEVICE_NAMES].join(', ')}.`);
    }
}
/** Map a Tailscale `OS` field to our platform enum. */
export function platformFromOs(os) {
    switch ((os ?? '').toLowerCase()) {
        case 'windows':
            return 'windows';
        case 'linux':
            return 'linux';
        case 'macos':
        case 'darwin':
            return 'macos';
        default:
            return 'unknown';
    }
}
/** The remote shell a platform speaks. */
export function shellForPlatform(platform) {
    return platform === 'windows' ? 'powershell' : 'posix';
}
/**
 * Run `fn` while holding an exclusive cross-process lock on the registry file.
 * proper-lockfile requires the target to exist, so we touch it first. Stale
 * locks (from crashed callers) auto-expire after `stale` ms.
 */
async function withRegistryLock(p, fn) {
    await fs.mkdir(path.dirname(p), { recursive: true });
    if (!fsSync.existsSync(p)) {
        try {
            await fs.writeFile(p, '{}', { flag: 'wx' });
        }
        catch (err) {
            if (err && err.code !== 'EEXIST')
                throw err;
        }
    }
    const release = await lockfile.lock(p, {
        retries: { retries: 60, minTimeout: 25, maxTimeout: 250, factor: 1.5 },
        stale: 10_000,
        onCompromised: logAndContinueOnLockCompromised('devices registry'),
    });
    try {
        return await fn();
    }
    finally {
        await release();
    }
}
/**
 * Load all devices from the registry file. Returns an empty object only when
 * the file does not exist. A malformed file is a hard error — silently
 * returning {} would let the next write wipe the user's device list.
 */
export async function loadDevices() {
    const p = registryPath();
    let raw;
    try {
        raw = await fs.readFile(p, 'utf-8');
    }
    catch (err) {
        if (err && err.code === 'ENOENT')
            return {};
        throw err;
    }
    try {
        return JSON.parse(raw);
    }
    catch (err) {
        throw new Error(`Device registry corrupted at ${p}: ${err?.message ?? err}. Inspect and restore from backup.`);
    }
}
/**
 * Synchronous {@link loadDevices} for callers already on a sync path (e.g. the
 * `agents sessions --device` fan-out builds its ssh command strings synchronously
 * and only needs the target's platform to pick POSIX vs PowerShell). Same
 * missing-file/corruption contract as {@link loadDevices}.
 */
export function loadDevicesSync() {
    const p = registryPath();
    let raw;
    try {
        raw = fsSync.readFileSync(p, 'utf-8');
    }
    catch (err) {
        if (err && err.code === 'ENOENT')
            return {};
        throw err;
    }
    try {
        return JSON.parse(raw);
    }
    catch (err) {
        throw new Error(`Device registry corrupted at ${p}: ${err?.message ?? err}. Inspect and restore from backup.`);
    }
}
async function saveDevices(reg) {
    atomicWriteJsonSync(registryPath(), reg);
}
/** Get a single device profile, or null if it is not registered. */
export async function getDevice(name) {
    const reg = await loadDevices();
    return reg[name] ?? null;
}
/**
 * Create the device if absent, otherwise merge the supplied fields into the
 * existing profile. `shell` is always re-derived from the (possibly new)
 * platform so the two can never drift. Returns the resulting profile.
 */
export async function upsertDevice(name, input) {
    assertValidDeviceName(name);
    const p = registryPath();
    return withRegistryLock(p, async () => {
        const reg = await loadDevices();
        const now = new Date().toISOString();
        const prev = reg[name];
        const platform = input.platform ?? prev?.platform ?? 'unknown';
        const merged = {
            name,
            platform,
            shell: shellForPlatform(platform),
            user: input.user ?? prev?.user,
            address: input.address ?? prev?.address ?? { via: 'manual' },
            auth: input.auth ?? prev?.auth ?? { method: 'key' },
            tailscale: input.tailscale ?? prev?.tailscale,
            reachability: input.reachability ?? prev?.reachability,
            createdAt: prev?.createdAt ?? now,
            updatedAt: now,
        };
        reg[name] = merged;
        await saveDevices(reg);
        return merged;
    });
}
/**
 * Persist live reachability verdicts for many devices in one locked pass
 * (RUSH-1965). A no-op verdict (same `reachable` and not newer than what's
 * stored) is skipped, so a cache-served `agents devices` render doesn't churn
 * the registry every call; only a flip or a fresher probe writes. Unlike
 * {@link upsertDevice} this does NOT bump `updatedAt` — reachability is
 * transient liveness (like the tailscale snapshot), not a profile edit. Unknown
 * device names are ignored. Returns the names actually updated.
 */
export async function writeReachability(updates) {
    const names = Object.keys(updates);
    if (names.length === 0)
        return [];
    const p = registryPath();
    return withRegistryLock(p, async () => {
        const reg = await loadDevices();
        const changed = [];
        for (const name of names) {
            const prev = reg[name];
            if (!prev)
                continue; // never resurrect a device the user removed
            const next = updates[name];
            const cur = prev.reachability;
            if (cur &&
                cur.reachable === next.reachable &&
                Date.parse(cur.checkedAt) >= Date.parse(next.checkedAt)) {
                continue; // unchanged verdict, no fresher timestamp — skip the write
            }
            reg[name] = { ...prev, reachability: next };
            changed.push(name);
        }
        if (changed.length > 0)
            await saveDevices(reg);
        return changed;
    });
}
/** Remove a device. Returns false if it was not registered. */
export async function removeDevice(name) {
    const p = registryPath();
    return withRegistryLock(p, async () => {
        const reg = await loadDevices();
        if (!reg[name])
            return false;
        delete reg[name];
        await saveDevices(reg);
        removeStatsCacheEntry(name);
        return true;
    });
}
/**
 * The full ignore-list entries — who dismissed a node, when, and on which box —
 * the typed read side for `agents devices ignored`. Absent `fleet.ignored` =>
 * []. A malformed block is a hard error for the same reason the registry is:
 * silently returning [] would let the next write wipe the user's dismissals.
 */
/** Validate a raw ignore-list block, or throw with `where` naming the file. */
function assertIgnoredShape(raw, where) {
    if (!Array.isArray(raw) ||
        raw.some((e) => !e ||
            typeof e.name !== 'string' ||
            typeof e.ignoredAt !== 'string' ||
            typeof e.ignoredOn !== 'string')) {
        throw new Error(`Device ignore-list corrupted in ${where}: expected a list of { name, ignoredAt, ignoredOn } entries. Inspect and repair it.`);
    }
}
/**
 * THIS box's OWN dismissals — the writable slice in `meta.deviceFleet.ignored`
 * (the device doc). `withIgnoredAdded`/`removeIgnored` operate on this so a box
 * only ever edits its own folder (PHNX-3315). Absent => [].
 */
function loadOwnIgnoredEntries(meta) {
    const raw = meta.deviceFleet?.ignored;
    if (raw === undefined)
        return [];
    assertIgnoredShape(raw, `devices/<machine>/agents.yaml (fleet.ignored)`);
    return raw;
}
/**
 * The EFFECTIVE ignore-list: the union of every box's device doc
 * `fleet.ignored` (deduped by node name, newest `ignoredAt` winning) plus any
 * lingering central-legacy `fleet.ignored` block (drained by the migration).
 * Deterministic and order-independent. A malformed block is a hard error for the
 * same reason the registry is: silently returning [] would let the next write
 * wipe the user's dismissals.
 */
export function loadIgnoredEntries(meta = readMeta()) {
    const byName = new Map();
    const central = meta.fleet?.ignored;
    if (central !== undefined) {
        assertIgnoredShape(central, `agents.yaml (fleet.ignored)`);
        for (const e of central)
            addIgnoredEntry(byName, e);
    }
    for (const e of unionDeviceIgnored())
        addIgnoredEntry(byName, e);
    return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
}
/** Load the set of ignored node names. Same corruption contract as
 * {@link loadIgnoredEntries}. */
export async function loadIgnored() {
    return new Set(loadIgnoredEntries().map((e) => e.name));
}
/** True if `name` is on the ignore-list. */
export async function isIgnored(name) {
    return (await loadIgnored()).has(name);
}
/**
 * Union `names` into the meta's ignore-list, stamping new entries with
 * `ignoredAt` and THIS machine's id. Existing entries keep their original
 * who/when, so re-adding a name is a true no-op. Returns the input unchanged
 * when no name is new. Exported for the one-shot legacy-store migration in
 * lib/devices/config-migration.ts.
 */
export function withIgnoredAdded(meta, names, ignoredAt) {
    const entries = loadOwnIgnoredEntries(meta); // throws on a corrupted block — never wipe it
    const have = new Set(entries.map((e) => e.name));
    const fresh = names.filter((n) => !have.has(n));
    if (fresh.length === 0)
        return meta;
    const ignored = [
        ...entries,
        ...fresh.map((name) => ({ name, ignoredAt, ignoredOn: machineId() })),
    ].sort((a, b) => a.name.localeCompare(b.name));
    return { ...meta, deviceFleet: { ...meta.deviceFleet, ignored } };
}
/** Add a node name to THIS box's ignore-list (device doc). Idempotent. Returns
 * the resulting cross-box union of dismissed names. Reads only the device docs
 * for the return value — a corrupt central-legacy block surfaces loudly on the
 * effective read path ({@link loadIgnoredEntries}), never blocks a per-box
 * write that does not touch central at all. */
export async function addIgnored(name) {
    assertValidDeviceName(name);
    updateMeta((m) => withIgnoredAdded(m, [name], new Date().toISOString()));
    return new Set(unionDeviceIgnored().map((e) => e.name));
}
/** Remove a node name from the ignore-list (un-ignore). Returns false if it was
 * not ignored. */
export async function removeIgnored(name) {
    let removed = false;
    updateMeta((m) => {
        const entries = loadOwnIgnoredEntries(m); // only this box's own dismissals are ours to drop
        const next = entries.filter((e) => e.name !== name);
        if (next.length === entries.length)
            return m;
        removed = true;
        return { ...m, deviceFleet: { ...m.deviceFleet, ignored: next } };
    });
    return removed;
}
