/**
 * One-time migration to the current device-config + pins layout:
 *
 *   (a) central `fleet.devices.<name>.config` (the short-lived #2458 store)
 *       folds into each per-device doc's `config:` block — central wins
 *       (newest intent) — and is stripped from central;
 *   (b) legacy `.history/devices/auto-launch.json` flags fold into the doc
 *       `config:` too (oldest store — only fills keys not already set);
 *   (c) a top-level `defaultBrowserProfile:` in a device doc folds into that
 *       doc's `config:`;
 *   (d) agent pins (`agents:` / `isolatedAgents:`) leave the TRACKED
 *       per-device docs: THIS machine's pins move to the untracked
 *       `.history/devices/pins-<host>.json` (pins file wins on conflict — it
 *       is the destination); peers' pins are simply dropped from the tracked
 *       file (each peer owns/rewrites its own pins locally).
 *   (e) legacy `.history/devices/ignored.json` (per-machine, UNTRACKED — a
 *       dismissal never reached the rest of the fleet) folds into the central
 *       TRACKED `fleet.ignored` list (RUSH-3062). Entries keep the legacy
 *       file's `updatedAt` as `ignoredAt` and take THIS machine's id as
 *       `ignoredOn` — the legacy store recorded neither, so the folding box
 *       is the only attribution available. The legacy file is removed only
 *       AFTER the central write lands.
 *
 * What stays put: a device doc's existing `config:` (already the right home)
 * and its `routines:` list (operator-owned, read cross-device by
 * routine-activation.ts).
 *
 * Order is crash-safe: destination writes (pins file, device doc) land BEFORE
 * the source strip (central), so a crash mid-fold re-folds on the next run and
 * the destination-wins merges make that a no-op. Idempotent — after a
 * successful run none of the legacy locations hold data, so re-running is a
 * cheap existence check. A doc that fails to parse is LOUDLY skipped (left
 * untouched) so the next run retries — never silently emptied, same corruption
 * contract the device registry keeps.
 *
 * Invoked from three places so every install converges regardless of entry
 * point: `runMigration()` (fresh / sentinel-less installs), daemon boot, and
 * the first `lib/device-config.ts` read/write in a process.
 */
import * as fs from 'fs';
import * as path from 'path';
import * as yaml from 'yaml';
import { META_HEADER, getDevicesAutoLaunchPath, getDevicesIgnoredPath, getDevicePinsPath, getUserAgentsDir, readMeta, updateMeta, withMetaLock, } from '../state.js';
import { atomicWriteFileSync } from '../fs-atomic.js';
import { machineId } from '../machine-id.js';
import { withIgnoredAdded } from './registry.js';
import { addIgnoredEntry } from './device-docs.js';
/** Read every device doc. A doc that fails to parse is loudly skipped. */
function readDeviceDocs(devicesRoot) {
    const docs = [];
    let entries;
    try {
        entries = fs.readdirSync(devicesRoot, { withFileTypes: true });
    }
    catch {
        return docs; // no devices/ tree — nothing to fold
    }
    for (const entry of entries) {
        if (!entry.isDirectory())
            continue;
        const docPath = path.join(devicesRoot, entry.name, 'agents.yaml');
        if (!fs.existsSync(docPath))
            continue;
        let parsed;
        try {
            parsed = yaml.parse(fs.readFileSync(docPath, 'utf-8'));
        }
        catch (err) {
            console.error(`device config migration: could not parse ${docPath} (${err.message}); leaving it for a later retry`);
            continue;
        }
        if (parsed === null || parsed === undefined)
            continue; // empty doc
        if (typeof parsed !== 'object' || Array.isArray(parsed)) {
            console.error(`device config migration: ${docPath} is not a YAML map; leaving it for manual repair`);
            continue;
        }
        docs.push({ name: entry.name, path: docPath, doc: parsed });
    }
    return docs;
}
/** Write a device doc, or remove it (and its dir) when nothing remains. */
function writeDeviceDoc(docPath, doc) {
    try {
        if (Object.keys(doc).length === 0) {
            fs.rmSync(docPath, { force: true });
            try {
                fs.rmdirSync(path.dirname(docPath));
            }
            catch { /* not empty — other files live in the device dir */ }
        }
        else {
            fs.mkdirSync(path.dirname(docPath), { recursive: true });
            atomicWriteFileSync(docPath, META_HEADER + yaml.stringify(doc));
        }
    }
    catch (err) {
        console.error(`device config migration: could not rewrite ${docPath} (${err.message}); a later run retries`);
    }
}
/** The legacy auto-launch.json flags, keyed by device name ({} when absent). */
function readAutoLaunchFlags(autoLaunchPath) {
    const out = {};
    let raw;
    try {
        raw = fs.readFileSync(autoLaunchPath, 'utf-8');
    }
    catch {
        return out; // absent — nothing to fold
    }
    let parsed;
    try {
        parsed = JSON.parse(raw);
    }
    catch (err) {
        console.error(`device config migration: could not parse ${autoLaunchPath} (${err.message}); leaving it for a later retry`);
        return out;
    }
    const devices = parsed?.devices;
    if (devices && typeof devices === 'object' && !Array.isArray(devices)) {
        for (const [name, pref] of Object.entries(devices)) {
            const config = {};
            if (pref?.enabled === false)
                config.autoLaunchEnabled = false;
            if (pref?.preferred === true)
                config.autoLaunchPreferred = true;
            if (Object.keys(config).length > 0)
                out[name] = config;
        }
    }
    return out;
}
function isConfigMap(v) {
    return typeof v === 'object' && v !== null && !Array.isArray(v);
}
/**
 * The legacy per-machine ignored.json. Absent => null. A parse failure is
 * logged loudly and reported as `undefined` so the caller LEAVES the file in
 * place for a later retry — never silently emptied, the same corruption
 * contract the ignore-list itself keeps (registry.ts's loadIgnoredEntries).
 */
function readLegacyIgnoredFile(p) {
    let raw;
    try {
        raw = fs.readFileSync(p, 'utf-8');
    }
    catch {
        return null; // absent — nothing to fold
    }
    try {
        const parsed = JSON.parse(raw);
        return {
            names: Array.isArray(parsed.ignored) ? parsed.ignored.filter((n) => typeof n === 'string') : [],
            updatedAt: typeof parsed.updatedAt === 'string' ? parsed.updatedAt : undefined,
        };
    }
    catch (err) {
        console.error(`device config migration: could not parse ${p} (${err.message}); leaving it for a later retry`);
        return undefined;
    }
}
/**
 * Fold every legacy device-config/pins location into the current layout. Safe
 * to call on every boot / config access: cheap no-op once folded.
 */
export function migrateDeviceConfigStores() {
    const devicesRoot = path.join(getUserAgentsDir(), 'devices');
    const autoLaunchPath = getDevicesAutoLaunchPath();
    const self = machineId();
    // ── 1. Gather ────────────────────────────────────────────────────────────
    // Central per-device config (the #2458 store).
    const fleet = readMeta().fleet;
    const centralDevices = fleet && fleet.devices !== 'all' ? fleet.devices : {};
    const centralHasConfig = Object.values(centralDevices).some((ov) => ov?.config && Object.keys(ov.config).length > 0);
    const autoLaunchFlags = readAutoLaunchFlags(autoLaunchPath);
    const docs = readDeviceDocs(devicesRoot);
    const legacyIgnoredPath = getDevicesIgnoredPath();
    const legacyIgnoredPending = fs.existsSync(legacyIgnoredPath);
    const legacyIgnored = legacyIgnoredPending ? readLegacyIgnoredFile(legacyIgnoredPath) : null;
    // Central shared fleet.discovery / fleet.ignored (PHNX-3315): the maps N boxes
    // used to rewrite. Folded into THIS box's device doc below, then stripped.
    const centralFleetState = !!(fleet &&
        ((fleet.discovery && Object.keys(fleet.discovery).length > 0) ||
            (Array.isArray(fleet.ignored) && fleet.ignored.length > 0)));
    // Central shared `hosts:` map (PHNX-3315): the host registry N boxes used to
    // rewrite. Folded into THIS box's device doc below, then the central key is
    // dropped entirely.
    const centralHosts = readMeta().hosts;
    const centralHostsPending = !!(centralHosts && Object.keys(centralHosts).length > 0);
    // Central device-scoped native accounts (PHNX-3315): a native login is
    // machine-local, so `scope:'device'` identities (and the bindings that target
    // them) belong in this box's device doc, off the git-tracked shared file.
    const centralAccounts = readMeta().accounts;
    const accountsPending = !!(centralAccounts?.native && Object.values(centralAccounts.native).some((a) => a.scope === 'device'));
    const pinsPath = getDevicePinsPath();
    let selfPins = {};
    try {
        selfPins = JSON.parse(fs.readFileSync(pinsPath, 'utf-8')) || {};
    }
    catch { /* absent or malformed — the fold below recreates it */ }
    // ── 1b. Plan (pure) — so a converged install never takes the meta lock or
    //    rewrites a byte-identical doc on every boot.
    const plans = [];
    for (const { name, path: docPath, doc } of docs) {
        const plan = planDeviceDocFold(name, docPath, doc, centralDevices[name]?.config, autoLaunchFlags[name]);
        if (plan)
            plans.push(plan);
    }
    const docNames = new Set(docs.map((d) => d.name));
    const newDocs = [];
    for (const [name, ov] of Object.entries(centralDevices)) {
        if (docNames.has(name) || !isConfigMap(ov?.config))
            continue;
        newDocs.push({ path: path.join(devicesRoot, name, 'agents.yaml'), doc: { config: { ...autoLaunchFlags[name], ...ov.config } } });
    }
    for (const [name, flags] of Object.entries(autoLaunchFlags)) {
        if (docNames.has(name) || isConfigMap(centralDevices[name]?.config))
            continue;
        newDocs.push({ path: path.join(devicesRoot, name, 'agents.yaml'), doc: { config: { ...flags } } });
    }
    const selfDoc = docs.find((d) => d.name === self);
    const docAgents = isConfigMap(selfDoc?.doc.agents) ? selfDoc.doc.agents : undefined;
    const docIsolated = isConfigMap(selfDoc?.doc.isolatedAgents)
        ? selfDoc.doc.isolatedAgents
        : undefined;
    const hasDestinationWork = plans.length > 0 || newDocs.length > 0 || docAgents !== undefined || docIsolated !== undefined;
    const autoLaunchPending = fs.existsSync(autoLaunchPath);
    if (!centralHasConfig && !hasDestinationWork && !autoLaunchPending && !legacyIgnoredPending && !centralFleetState && !centralHostsPending && !accountsPending)
        return;
    // ── 2. Destination writes FIRST (crash-safe), under the meta lock so they
    //    serialize against writeMetaUnlocked's own read-merge-write of the doc.
    //    Only taken when there IS a write — a converged install never locks
    //    (taking it would create a default central agents.yaml as a side effect).
    if (hasDestinationWork) {
        withMetaLock(() => {
            // 2a. THIS machine's pins: doc pins merge INTO the pins file (pins file
            //     wins — it is the destination, and a re-fold after a crash must not
            //     clobber pins the new CLI already wrote).
            if (docAgents || docIsolated) {
                const pins = { ...selfPins };
                if (docAgents)
                    pins.agents = { ...docAgents, ...selfPins.agents };
                if (docIsolated)
                    pins.isolatedAgents = { ...docIsolated, ...selfPins.isolatedAgents };
                try {
                    fs.mkdirSync(path.dirname(pinsPath), { recursive: true });
                    atomicWriteFileSync(pinsPath, JSON.stringify(pins, null, 2) + '\n');
                }
                catch (err) {
                    console.error(`device config migration: could not write ${pinsPath} (${err.message}); a later run retries`);
                }
            }
            // 2b/2c. The planned doc rewrites + creations.
            for (const plan of plans)
                writeDeviceDoc(plan.path, plan.next);
            for (const nd of newDocs)
                writeDeviceDoc(nd.path, nd.doc);
        });
    }
    // ── 3. Source strips LAST ────────────────────────────────────────────────
    // 3a. Central: drop every devices.<name>.config block (now folded into the
    //     device docs). Overrides left with no other fields are dropped; an
    //     emptied fleet block (no defaults/secrets/routines) goes away entirely.
    if (centralHasConfig) {
        updateMeta((m) => {
            const devices = m.fleet?.devices;
            if (!devices || devices === 'all')
                return m;
            const nextDevices = {};
            for (const [name, ov] of Object.entries(devices)) {
                const rest = { ...ov };
                delete rest.config;
                if (Object.keys(rest).length > 0)
                    nextDevices[name] = rest;
            }
            const fleet = { ...m.fleet, devices: nextDevices };
            // Drop the whole `fleet` block only when NOTHING else lives in it. Every
            // resident must be named here: `ignored` holds the user's dismissals and
            // `discovery` their discovery policy, and deleting the block would not
            // just lose them locally — `agents repo push` would sync the deletion
            // fleet-wide. Add any new fleet.* key to this guard.
            const fleetIsEmpty = Object.keys(nextDevices).length === 0 &&
                !fleet.defaults &&
                !fleet.secrets &&
                !fleet.routines &&
                !fleet.discovery &&
                !(fleet.ignored && fleet.ignored.length > 0);
            if (fleetIsEmpty) {
                const { fleet: _, ...rest } = m;
                void _;
                return rest;
            }
            return { ...m, fleet };
        });
    }
    // 3b. The legacy auto-launch.json.
    if (autoLaunchPending) {
        try {
            fs.rmSync(autoLaunchPath, { force: true });
        }
        catch (err) {
            console.error(`device config migration: could not remove ${autoLaunchPath} (${err.message}); a later run retries`);
        }
    }
    // ── 4. Legacy ignored.json → central fleet.ignored ───────────────────────
    // Destination write (updateMeta: withMetaLock + atomic write) lands BEFORE
    // the legacy file is removed, so a crash re-folds on the next run and
    // withIgnoredAdded's union-by-name makes that a no-op. A file that failed to
    // parse (legacyIgnored === undefined) is left in place for that retry.
    if (legacyIgnored) {
        if (legacyIgnored.names.length > 0) {
            updateMeta((m) => withIgnoredAdded(m, legacyIgnored.names, legacyIgnored.updatedAt ?? new Date().toISOString()));
        }
        try {
            fs.rmSync(legacyIgnoredPath, { force: true });
        }
        catch (err) {
            console.error(`device config migration: could not remove ${legacyIgnoredPath} (${err.message}); a later run retries`);
        }
    }
    // ── 5. Central fleet.discovery / fleet.ignored → THIS box's device doc ─────
    // (PHNX-3315) The shared maps every box used to rewrite fold into this box's
    // deviceFleet, then are stripped from central. writeMetaUnlocked writes the
    // device doc BEFORE the central strip, so a crash re-folds on the next run and
    // the union-dedup (ignored beats approved; newest ignoredAt wins) makes the
    // re-fold a no-op. Idempotent — once central holds neither key the gather
    // guard above skips this whole pass.
    if (centralFleetState) {
        updateMeta((m) => {
            const disc = m.fleet?.discovery;
            const ign = m.fleet?.ignored;
            const hasDisc = !!disc && Object.keys(disc).length > 0;
            const hasIgn = Array.isArray(ign) && ign.length > 0;
            if (!hasDisc && !hasIgn)
                return m;
            const discovery = { ...m.deviceFleet?.discovery };
            if (hasDisc) {
                for (const [name, status] of Object.entries(disc)) {
                    if (status !== 'approved' && status !== 'ignored')
                        continue;
                    if (discovery[name] === 'ignored')
                        continue; // ignored is never downgraded
                    discovery[name] = status;
                }
            }
            const byName = new Map();
            for (const e of m.deviceFleet?.ignored ?? [])
                addIgnoredEntry(byName, e);
            if (hasIgn)
                for (const e of ign)
                    addIgnoredEntry(byName, e);
            const ignored = [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
            const deviceFleet = {
                ...(Object.keys(discovery).length > 0 ? { discovery } : {}),
                ...(ignored.length > 0 ? { ignored } : {}),
            };
            const fleet = m.fleet ? { ...m.fleet } : undefined;
            if (fleet) {
                delete fleet.discovery;
                delete fleet.ignored;
            }
            // Drop an emptied fleet block entirely (mirrors step 3a's guard) so the
            // strip does not leave a bare `fleet: { devices: {} }` behind.
            const fleetEmpty = !fleet ||
                ((fleet.devices === undefined ||
                    (fleet.devices !== 'all' && Object.keys(fleet.devices).length === 0)) &&
                    !fleet.defaults &&
                    !fleet.secrets &&
                    !fleet.routines);
            if (fleetEmpty) {
                const { fleet: _drop, ...rest } = m;
                void _drop;
                return { ...rest, deviceFleet };
            }
            return { ...m, deviceFleet, fleet };
        });
    }
    // ── 6. Central hosts map → THIS box's device doc (PHNX-3315) ───────────────
    // The shared host registry folds into this box's deviceHosts, then the central
    // key is dropped. writeMetaUnlocked writes the device doc before the central
    // strip, so a crash re-folds and the newest-addedAt union makes it a no-op.
    // Idempotent — once central holds no `hosts:` the gather guard skips this.
    if (centralHostsPending) {
        updateMeta((m) => {
            const hosts = m.hosts;
            if (!hosts || Object.keys(hosts).length === 0)
                return m;
            // This box's own device-doc entries win over the shared legacy on a name
            // collision (a re-fold after a crash must not clobber a fresher local edit).
            const deviceHosts = { ...hosts, ...m.deviceHosts };
            const { hosts: _drop, ...rest } = m;
            void _drop;
            return { ...rest, deviceHosts };
        });
    }
    // ── 7. Central device-scoped native accounts → THIS box's device doc ───────
    // (PHNX-3315) Fold `scope:'device'` natives and the bindings that target them
    // out of central and into the device doc, removing their identity PII from the
    // git-tracked shared file. The selection is recomputed INSIDE the lock so a
    // concurrent write is never clobbered; the device-doc write precedes the
    // central strip, so a crash re-folds and the id-keyed merge makes it a no-op.
    if (accountsPending) {
        updateMeta((m) => {
            const native = { ...m.accounts?.native };
            const bindings = { ...m.accounts?.bindings };
            const devNative = { ...m.deviceAccounts?.native };
            const devBindings = { ...m.deviceAccounts?.bindings };
            const movedIds = new Set();
            let changed = false;
            for (const [id, entry] of Object.entries(native)) {
                if (entry.scope === 'device') {
                    devNative[id] = entry;
                    movedIds.add(id);
                    delete native[id];
                    changed = true;
                }
            }
            for (const [target, id] of Object.entries(bindings)) {
                if (movedIds.has(id)) {
                    devBindings[target] = id;
                    delete bindings[target];
                    changed = true;
                }
            }
            if (!changed)
                return m;
            const deviceAccounts = {
                ...(Object.keys(devNative).length > 0 ? { native: devNative } : {}),
                ...(Object.keys(devBindings).length > 0 ? { bindings: devBindings } : {}),
            };
            const accounts = { ...m.accounts };
            if (Object.keys(native).length > 0)
                accounts.native = native;
            else
                delete accounts.native;
            if (Object.keys(bindings).length > 0)
                accounts.bindings = bindings;
            else
                delete accounts.bindings;
            // Drop the whole central `accounts` block only when nothing fleet-shared
            // remains (no version natives, no bindings, no defaults).
            const accountsEmpty = !accounts.native && !accounts.bindings && !accounts.defaults;
            if (accountsEmpty) {
                const { accounts: _drop, ...rest } = m;
                void _drop;
                return { ...rest, deviceAccounts };
            }
            return { ...m, accounts, deviceAccounts };
        });
    }
}
/** Shallow-set equality for folded doc content (values compared deep via JSON). */
function sameDocContent(a, b) {
    const ak = Object.keys(a);
    const bk = Object.keys(b);
    if (ak.length !== bk.length)
        return false;
    return ak.every((k) => k in b && JSON.stringify(a[k]) === JSON.stringify(b[k]));
}
/**
 * Compute a device doc's post-fold content: merge the config layers (oldest
 * auto-launch.json flags < the doc's own config: < the central #2458 block —
 * newest wins), fold a top-level defaultBrowserProfile into config:, and strip
 * pins from the tracked file. Returns null when the doc is corrupt (loudly —
 * left for manual repair) or the fold would not change its content.
 */
function planDeviceDocFold(name, docPath, doc, centralConfig, autoFlags) {
    const config = {};
    Object.assign(config, autoFlags);
    if (doc.config !== undefined) {
        if (!isConfigMap(doc.config)) {
            console.error(`device config migration: ${docPath} has a non-map config: block; leaving it for manual repair`);
            return null;
        }
        Object.assign(config, doc.config);
    }
    if (typeof doc.defaultBrowserProfile === 'string' && config.defaultBrowserProfile === undefined) {
        config.defaultBrowserProfile = doc.defaultBrowserProfile;
    }
    if (isConfigMap(centralConfig))
        Object.assign(config, centralConfig);
    const next = {};
    // Preserve fields the migration does not own (routines:, anything else).
    for (const [k, v] of Object.entries(doc)) {
        if (k === 'config' || k === 'defaultBrowserProfile' || k === 'agents' || k === 'isolatedAgents')
            continue;
        next[k] = v;
    }
    if (Object.keys(config).length > 0)
        next.config = config;
    return sameDocContent(next, doc) ? null : { name, path: docPath, next };
}
