import { createHash } from 'node:crypto';
import { describe, it, expect, beforeEach } from 'vitest';
import type { AutoUpdateConfig } from '../src/config.js';
import { _autoUpdateIo } from '../src/daemon.js';

const MARKITDOWN_TARGETS_JSON = JSON.stringify([
  { platform: 'linux', arch: 'x64', assetName: 'markitdown-linux-x64', runner: 'ubuntu-latest' },
  { platform: 'darwin', arch: 'arm64', assetName: 'markitdown-darwin-arm64', runner: 'macos-14' },
  { platform: 'win32', arch: 'x64', assetName: 'markitdown-win32-x64.exe', runner: 'windows-latest' },
]);
const CLI_VERSION = '9.0.0-beta.6';
const MARKITDOWN_BUILD_INFO_JSON = JSON.stringify({
  markItDownUpstreamVersion: '0.1.5',
  pyInstallerVersion: '6.19.0',
});
const MOCK_MARKITDOWN_ENTRY_SCRIPT = '# mock markitdown entry script\n';
const MOCK_BUNDLER_SCRIPT = [
  "export const MARKITDOWN_UPSTREAM_VERSION = '0.1.5';",
  "export const PYINSTALLER_VERSION = '6.19.0';",
].join('\n');
const RUNTIME_PACKAGES_BUILD_CMD = 'pnpm build:runtime:packages';
const RUNTIME_BUILD_CMD = 'pnpm build:runtime';
const RUNTIME_BUILD_COMPAT_WRAPPER = 'pnpm run build:runtime:packages && pnpm --filter @origintrail-official/dkg-node-ui run build:ui';
const NODE_UI_BUILD_CMD = 'pnpm --filter @origintrail-official/dkg-node-ui run build:ui';
const LEGACY_NODE_UI_BUILD_CMD = 'pnpm --filter @dkg/node-ui run build:ui';
let mockBundledCliPackageVersion = CLI_VERSION;
let mockInstalledPackageVersion = '9.0.0-beta.4-dev.100.abc1234';

function buildFingerprintForTest(): string {
  return sha256HexForTest([
    '0.1.5',
    '6.19.0',
    sha256HexForTest(MOCK_MARKITDOWN_ENTRY_SCRIPT),
    sha256HexForTest(MOCK_BUNDLER_SCRIPT),
  ].join('\n'));
}

function mockReadFileSyncValue(path: unknown): string {
  const normalized = String(path).replace(/\\/g, '/');
  if (normalized.endsWith('/markitdown-targets.json')) return MARKITDOWN_TARGETS_JSON;
  if (normalized.endsWith('/markitdown-build-info.json')) return MARKITDOWN_BUILD_INFO_JSON;
  if (normalized.endsWith('/scripts/markitdown-entry.py')) return MOCK_MARKITDOWN_ENTRY_SCRIPT;
  if (normalized.endsWith('/scripts/bundle-markitdown-binaries.mjs')) return MOCK_BUNDLER_SCRIPT;
  if (normalized.includes('/node_modules/@origintrail-official/dkg/package.json')) {
    return JSON.stringify({ version: mockInstalledPackageVersion });
  }
  if (normalized.endsWith('/packages/cli/package.json')) {
    return JSON.stringify({ version: mockBundledCliPackageVersion });
  }
  return 'testtoken';
}

// Save original _autoUpdateIo values for restoration
const origIo = { ..._autoUpdateIo };

// Tracking arrays
let readFileCalls: [any, ...any[]][] = [];
let writeFileCalls: [any, any, ...any[]][] = [];
let mkdirCalls: any[][] = [];
let rmCalls: any[][] = [];
let readdirCalls: any[][] = [];
let copyFileCalls: any[][] = [];
let chmodCalls: any[][] = [];
let statCalls: any[][] = [];
let existsSyncCalls: any[][] = [];
let readFileSyncCalls: any[][] = [];
let openSyncCalls: any[][] = [];
let closeSyncCalls: any[][] = [];
let writeFileSyncCalls: any[][] = [];
let unlinkSyncCalls: any[][] = [];
let execCalls: { cmd: string; cwd: string; timeout?: number }[] = [];
let execFileCalls: { file: string; args: string[]; cwd: string; env: any }[] = [];
let swapSlotCalls: string[] = [];
let fetchCalls: any[][] = [];

// Default mock implementations
let readFileImpl: (path: any, ...rest: any[]) => Promise<any> = async () => '';
let writeFileImpl: (path: any, data: any, ...rest: any[]) => Promise<any> = async () => {};
let existsSyncImpl: (path: any) => boolean = () => true;
let readFileSyncImpl: (path: any, ...rest: any[]) => any = (path: any) => mockReadFileSyncValue(path);
let execImpl: (cmd: string, opts?: any) => Promise<any> = async () => ({ stdout: '', stderr: '' });
let execFileImpl: (file: string, args: string[], opts?: any) => Promise<any> = async () => ({ stdout: '', stderr: '' });
let swapSlotImpl: (slot: 'a' | 'b') => Promise<void> = async () => {};
let fetchImpl: (...args: any[]) => Promise<any> = async () => ({ ok: true, json: async () => ({}) });
// `cleanGeneratedOutputs` walks `<slot>/packages/<pkg>/{dist,tsconfig.tsbuildinfo}`
// and additionally wipes packages/cli's generated `network/` + `project.json`
// (copied from repo root by the cli build script).
// Default to a couple of canned package entries so the full flow runs end-to-end
// in tests that don't care; tests targeting the cleaner override this directly.
const DEFAULT_READDIR_PKG_ENTRIES = [
  { name: 'core', isDirectory: () => true },
  { name: 'cli', isDirectory: () => true },
  { name: 'README.md', isDirectory: () => false },
];
let readdirImpl: (path: any, opts?: any) => Promise<any[]> = async (path: any) => {
  if (String(path).endsWith('/packages')) return DEFAULT_READDIR_PKG_ENTRIES.slice();
  return [];
};

let mockActiveSlot = 'a';

function resetMocks() {
  readFileCalls = [];
  writeFileCalls = [];
  mkdirCalls = [];
  rmCalls = [];
  readdirCalls = [];
  copyFileCalls = [];
  chmodCalls = [];
  statCalls = [];
  existsSyncCalls = [];
  readFileSyncCalls = [];
  openSyncCalls = [];
  closeSyncCalls = [];
  writeFileSyncCalls = [];
  unlinkSyncCalls = [];
  execCalls = [];
  execFileCalls = [];
  swapSlotCalls = [];
  fetchCalls = [];

  readFileImpl = async () => '';
  writeFileImpl = async () => {};
  existsSyncImpl = () => true;
  readFileSyncImpl = (path: any) => mockReadFileSyncValue(path);
  execImpl = async (_cmd, _opts) => ({ stdout: '', stderr: '' });
  execFileImpl = async (_file, _args, _opts) => ({ stdout: '', stderr: '' });
  swapSlotImpl = async () => {};
  fetchImpl = async () => ({ ok: true, json: async () => ({}) });
  readdirImpl = async (path: any) => {
    if (String(path).endsWith('/packages')) return DEFAULT_READDIR_PKG_ENTRIES.slice();
    return [];
  };
}

function installMocks() {
  _autoUpdateIo.readFile = (async (path: any, ...rest: any[]) => {
    readFileCalls.push([path, ...rest]);
    return readFileImpl(path, ...rest);
  }) as any;
  _autoUpdateIo.writeFile = (async (path: any, data: any, ...rest: any[]) => {
    writeFileCalls.push([path, data, ...rest]);
    return writeFileImpl(path, data, ...rest);
  }) as any;
  _autoUpdateIo.mkdir = (async (...args: any[]) => { mkdirCalls.push(args); }) as any;
  _autoUpdateIo.rm = (async (...args: any[]) => { rmCalls.push(args); }) as any;
  _autoUpdateIo.readdir = (async (path: any, opts?: any) => {
    readdirCalls.push([path, opts]);
    return readdirImpl(path, opts);
  }) as any;
  _autoUpdateIo.chmod = (async (...args: any[]) => { chmodCalls.push(args); }) as any;
  _autoUpdateIo.copyFile = (async (...args: any[]) => { copyFileCalls.push(args); }) as any;
  _autoUpdateIo.stat = (async (...args: any[]) => { statCalls.push(args); return { mode: 0o755 }; }) as any;
  _autoUpdateIo.rename = (async () => {}) as any;
  _autoUpdateIo.unlink = (async () => {}) as any;
  _autoUpdateIo.existsSync = ((...args: any[]) => {
    existsSyncCalls.push(args);
    return existsSyncImpl(args[0]);
  }) as any;
  _autoUpdateIo.readFileSync = ((...args: any[]) => {
    readFileSyncCalls.push(args);
    return readFileSyncImpl(args[0], ...args.slice(1));
  }) as any;
  _autoUpdateIo.openSync = ((...args: any[]) => { openSyncCalls.push(args); return 99; }) as any;
  _autoUpdateIo.closeSync = ((...args: any[]) => { closeSyncCalls.push(args); }) as any;
  _autoUpdateIo.writeFileSync = ((...args: any[]) => { writeFileSyncCalls.push(args); }) as any;
  _autoUpdateIo.unlinkSync = ((...args: any[]) => { unlinkSyncCalls.push(args); }) as any;
  _autoUpdateIo.exec = (async (cmd: any, opts?: any) => {
    execCalls.push({ cmd: String(cmd), cwd: normalizePathString(opts?.cwd), timeout: opts?.timeout });
    return execImpl(String(cmd), opts);
  }) as any;
  _autoUpdateIo.execFile = (async (file: any, args: any[], opts?: any) => {
    execFileCalls.push({ file: String(file), args: args ?? [], cwd: normalizePathString(opts?.cwd), env: opts?.env });
    return execFileImpl(String(file), args ?? [], opts);
  }) as any;
  _autoUpdateIo.execSync = (() => '') as any;
  _autoUpdateIo.dkgDir = () => '/tmp/dkg-test';
  _autoUpdateIo.releasesDir = () => '/tmp/dkg-test/releases';
  _autoUpdateIo.activeSlot = (async () => mockActiveSlot as 'a' | 'b') as any;
  _autoUpdateIo.inactiveSlot = (async () => (mockActiveSlot === 'a' ? 'b' : 'a') as 'a' | 'b') as any;
  _autoUpdateIo.swapSlot = (async (slot: 'a' | 'b') => { swapSlotCalls.push(slot); return swapSlotImpl(slot); }) as any;
  _autoUpdateIo.fetch = (async (...args: any[]) => { fetchCalls.push(args); return fetchImpl(...args); }) as any;
  _autoUpdateIo.hasVerifiedBundledMarkItDownBinary = async () => false;
  _autoUpdateIo.expectedBundledMarkItDownBuildMetadata = () => null;
  _autoUpdateIo.readCliPackageVersion = (pkgDir: string) => {
    try {
      const raw = readFileSyncImpl(normalizePathString(pkgDir + '/package.json'), 'utf-8');
      return JSON.parse(raw).version ?? null;
    } catch { return null; }
  };
}

function restoreIo() {
  Object.assign(_autoUpdateIo, origIo);
}

import {
  checkForNewCommitWithStatus,
  checkForUpdate,
  normalizeGitRefInput,
  performUpdate,
  performNpmUpdate,
  resolveAutoUpdateGitRef,
} from '../src/daemon.js';

const AU: AutoUpdateConfig = {
  enabled: true,
  repo: 'owner/repo',
  branch: 'main',
  checkIntervalMinutes: 30,
};

describe('git auto-update ref normalization', () => {
  it('normalizes bare branch values to full branch refs', () => {
    expect(normalizeGitRefInput('main')).toBe('refs/heads/main');
    expect(normalizeGitRefInput('release/v10')).toBe('refs/heads/release/v10');
  });

  it('preserves full refs and lets autoUpdate.ref override branch', () => {
    expect(normalizeGitRefInput('refs/heads/main')).toBe('refs/heads/main');
    expect(normalizeGitRefInput('refs/tags/v10.0.5')).toBe('refs/tags/v10.0.5');
    expect(resolveAutoUpdateGitRef({
      enabled: true,
      repo: 'owner/repo',
      branch: 'main',
      ref: 'refs/heads/canary',
      checkIntervalMinutes: 30,
    })).toBe('refs/heads/canary');
  });

  it('rejects unsafe refs before they reach git', () => {
    expect(() => normalizeGitRefInput('main; rm -rf /')).toThrow(/invalid branch\/ref/);
    expect(() => normalizeGitRefInput('--upload-pack=/tmp/pwn')).toThrow(/invalid branch\/ref/);
    expect(() => normalizeGitRefInput('refs/heads/')).toThrow(/invalid branch\/ref/);
  });
});

function normalizePathString(value: unknown): string {
  return String(value).replace(/\\/g, '/');
}

function sha256HexForTest(value: string | Buffer): string {
  return createHash('sha256').update(value).digest('hex');
}

function makeFetchOk(sha: string) {
  fetchImpl = async () => ({
    ok: true,
    json: async () => ({ sha }),
  });
}

function mockGitUpdateReadFile(
  currentCommit = 'aaa111',
  cliVersion = '9.0.0',
  nodeUiPackageName = '@origintrail-official/dkg-node-ui',
  rootScripts: Record<string, string> = {
    'build:runtime:packages': RUNTIME_PACKAGES_BUILD_CMD,
    'build:runtime': RUNTIME_BUILD_COMPAT_WRAPPER,
  },
) {
  readFileImpl = async (path: any) => {
    const p = normalizePathString(path);
    if (p.endsWith('/packages/node-ui/package.json')) {
      return JSON.stringify({
        name: nodeUiPackageName,
        scripts: { 'build:ui': 'vite build' },
      });
    }
    if (p.endsWith('/package.json') && !p.endsWith('/packages/cli/package.json')) {
      return JSON.stringify({
        dkgBuild: { releaseRuntimeBuildScript: 'build:runtime:packages' },
        scripts: rootScripts,
      });
    }
    if (p.endsWith('/packages/cli/package.json')) {
      return JSON.stringify({ version: cliVersion });
    }
    if (p.endsWith('.update-pending.json')) throw new Error('ENOENT');
    return currentCommit;
  };
}

function getExecCalls() {
  return execCalls.map(c => ({
    cmd: c.cmd,
    cwd: c.cwd,
    timeout: c.timeout,
  }));
}

function getExecFileCalls() {
  return execFileCalls.map(c => ({
    file: c.file,
    args: c.args,
    cwd: c.cwd,
    env: c.env,
  }));
}

describe('blue-green checkForUpdate', () => {
  beforeEach(() => {
    resetMocks();
    mockActiveSlot = 'a';
    installMocks();
  });

  afterEach(() => {
    restoreIo();
  });

  it('skips when blue-green slots are not initialized', async () => {
    existsSyncImpl = () => false;
    const logCalls: string[] = [];
    const log = (msg: string) => { logCalls.push(msg); };
    const result = await performUpdate(AU, log);
    expect(result).toBe(false);
    expect(logCalls.some(m => m.includes('slots not initialized'))).toBe(true);
    expect(fetchCalls.length).toBe(0);
  });

  it('reinitializes missing target slot git metadata before fetch', async () => {
    existsSyncImpl = (p: any) => {
      const path = normalizePathString(p);
      if (path.endsWith('/releases/a')) return true;
      if (path.endsWith('/releases/b/.git')) return false;
      if (path.includes('cli.js')) return true;
      return true;
    };
    readFileImpl = async () => 'aaa111';
    makeFetchOk('bbb222');

    const result = await performUpdate(AU, () => {});
    expect(result).toBe(true);

    const gitCmds = getExecFileCalls();
    const targetDir = '/tmp/dkg-test/releases/b';
    expect(gitCmds.some(c => c.file === 'git' && c.args.join(' ') === 'init' && c.cwd === targetDir)).toBe(true);
    expect(gitCmds.some(c => c.file === 'git' && c.args[0] === 'fetch' && c.cwd === targetDir)).toBe(true);
  });

  it('uses ssh git transport with configured ssh key path', async () => {
    readFileImpl = async () => 'aaa1111';
    execFileImpl = async (file: string, args: string[], _opts?: any) => {
      if (file === 'git' && args[0] === 'ls-remote') return { stdout: 'bbb2222\trefs/heads/main\n', stderr: '' };
      return { stdout: '', stderr: '' };
    };

    const sshAu: AutoUpdateConfig = {
      ...AU,
      repo: 'git@github.com:owner/repo.git',
      sshKeyPath: '/tmp/test key',
    };

    const result = await checkForNewCommitWithStatus(sshAu, () => {});
    expect(result.status).toBe('available');
    expect(fetchCalls.length).toBe(0);

    const gitCmds = getExecFileCalls().filter(c => c.file === 'git' && c.args[0] === 'ls-remote');
    expect(gitCmds.length).toBeGreaterThan(0);
    expect(gitCmds.every(c => c.env?.GIT_SSH_COMMAND === "ssh -i '/tmp/test key' -o IdentitiesOnly=yes")).toBe(true);
  });

  it('passes GITHUB_TOKEN to git fetch for https GitHub repos', async () => {
    const origToken = process.env.GITHUB_TOKEN;
    process.env.GITHUB_TOKEN = 'ghp_test_123';
    readFileImpl = async () => 'aaa111';
    makeFetchOk('bbb222');

    try {
      const result = await performUpdate({ ...AU, repo: 'https://github.com/owner/repo.git' }, () => {});
      expect(result).toBe(true);

      const fetchCall = getExecFileCalls().find(c => c.file === 'git' && c.args.includes('fetch'));
      expect(fetchCall).toBeTruthy();
      expect(fetchCall?.args[0]).toBe('-c');
      expect(fetchCall?.args[1]).toContain('http.extraHeader=Authorization: Basic ');
      expect(fetchCall?.args).toContain('fetch');
      expect(fetchCall?.args).toContain('https://github.com/owner/repo.git');
    } finally {
      if (origToken === undefined) delete process.env.GITHUB_TOKEN;
      else process.env.GITHUB_TOKEN = origToken;
    }
  });

  it('bootstraps from an npm-built active slot with no current git commit metadata', async () => {
    const latest = 'bbb222';
    readFileImpl = async (path: any) => {
      const normalized = normalizePathString(path);
      if (normalized.endsWith('/.current-commit')) throw new Error('ENOENT');
      if (normalized.endsWith('/.update-pending.json')) throw new Error('ENOENT');
      return '';
    };
    existsSyncImpl = (path: any) => {
      const normalized = normalizePathString(path);
      if (normalized === '/tmp/dkg-test/releases/a') return true;
      if (normalized.endsWith('/releases/b/.git')) return false;
      if (normalized.includes('cli.js')) return true;
      if (normalized.endsWith('/packages/node-ui/dist-ui/index.html')) return true;
      return true;
    };
    execImpl = async (cmd: string, opts?: any) => {
      if (cmd.includes('git rev-parse HEAD') && normalizePathString(opts?.cwd) === '/tmp/dkg-test/releases/a') {
        throw new Error('fatal: not a git repository');
      }
      return { stdout: '', stderr: '' };
    };
    makeFetchOk(latest);

    const logCalls: string[] = [];
    const result = await performUpdate(AU, (message) => logCalls.push(message));

    expect(result).toBe(true);
    expect(logCalls.some(m => m.includes('active slot git commit is unknown'))).toBe(true);
    const allCmds = getExecCalls();
    const gitCmds = getExecFileCalls();
    const activeDir = '/tmp/dkg-test/releases/a';
    const targetDir = '/tmp/dkg-test/releases/b';
    expect(allCmds.some(c => c.cmd.includes('git rev-parse HEAD') && c.cwd === activeDir)).toBe(true);
    expect(gitCmds.some(c => c.file === 'git' && c.args.join(' ') === 'init' && c.cwd === targetDir)).toBe(true);
    expect(gitCmds.some(c => c.file === 'git' && c.args[0] === 'fetch' && c.cwd === targetDir)).toBe(true);
    expect(allCmds.some(c => c.cmd.includes('pnpm install') && c.cwd === targetDir)).toBe(true);
    expect(allCmds.some(c => c.cmd.includes('pnpm build') && c.cwd === targetDir)).toBe(true);
    expect(swapSlotCalls).toContain('b');
  });

  it('skips when no new commit', async () => {
    const sha = 'abc123';
    readFileImpl = async () => sha;
    makeFetchOk(sha);

    const logCalls: string[] = [];
    const log = (msg: string) => { logCalls.push(msg); };
    const result = await performUpdate(AU, log);
    expect(result).toBe(false);
    expect(execCalls.length).toBe(0);
  });

  it('builds in inactive slot on new commit', async () => {
    const current = 'aaa111';
    const latest = 'bbb222';
    readFileImpl = async () => current;
    makeFetchOk(latest);

    const logCalls: string[] = [];
    const log = (msg: string) => { logCalls.push(msg); };
    const result = await performUpdate(AU, log);
    expect(result).toBe(true);

    const allCmds = getExecCalls();
    const gitCmds = getExecFileCalls();
    const targetDir = '/tmp/dkg-test/releases/b';
    expect(gitCmds.some(c => c.file === 'git' && c.args[0] === 'fetch' && c.cwd === targetDir)).toBe(true);
    expect(gitCmds.some(c => c.file === 'git' && c.args[0] === 'checkout' && c.cwd === targetDir)).toBe(true);
    expect(allCmds.some(c => c.cmd.includes('pnpm install') && c.cwd === targetDir)).toBe(true);
    expect(allCmds.some(c => c.cmd.includes('pnpm build') && c.cwd === targetDir)).toBe(true);
    expect(existsSyncCalls.some(([p]) =>
      normalizePathString(p).endsWith('/packages/node-ui/dist-ui/index.html')
    )).toBe(true);
    expect(allCmds.some(c => c.cmd.includes('bundle-markitdown-binaries.mjs') && c.cwd === targetDir)).toBe(true);
    expect(allCmds.some(c => c.cmd.includes('--force') && c.cwd === targetDir)).toBe(false);
    expect(allCmds.some(c => c.cmd.includes('--best-effort') && c.cwd === targetDir)).toBe(true);
    expect(allCmds.some(c => c.cmd.includes('pnpm --filter @origintrail-official/dkg-evm-module build') && c.cwd === targetDir)).toBe(false);

    const activeDir = '/tmp/dkg-test/releases/a';
    expect(allCmds.every(c => c.cwd !== activeDir)).toBe(true);
  });

  it('runs the Node UI static build after the runtime package build before swapping', async () => {
    const current = 'aaa111';
    const latest = 'bbb224';
    mockGitUpdateReadFile(current);
    makeFetchOk(latest);
    let nodeUiBuilt = false;
    existsSyncImpl = (p: any) => {
      const path = normalizePathString(p);
      if (path.endsWith('/packages/node-ui/dist-ui/index.html')) return nodeUiBuilt;
      return true;
    };
    execImpl = async (cmd: string) => {
      if (cmd === NODE_UI_BUILD_CMD) nodeUiBuilt = true;
      return { stdout: '', stderr: '' };
    };

    const result = await performUpdate(AU, () => {});
    expect(result).toBe(true);

    const targetDir = '/tmp/dkg-test/releases/b';
    const targetCalls = getExecCalls().filter(c => c.cwd === targetDir);
    const allCmds = targetCalls.map(c => c.cmd);
    const runtimeIdx = allCmds.indexOf(RUNTIME_PACKAGES_BUILD_CMD);
    const uiIdx = allCmds.indexOf(NODE_UI_BUILD_CMD);
    expect(runtimeIdx).toBeGreaterThanOrEqual(0);
    expect(uiIdx).toBeGreaterThan(runtimeIdx);
    expect(targetCalls.find(c => c.cmd === RUNTIME_PACKAGES_BUILD_CMD)?.timeout).toBe(180_000);
    expect(targetCalls.find(c => c.cmd === NODE_UI_BUILD_CMD)?.timeout).toBe(180_000);
    expect(allCmds).not.toContain(RUNTIME_BUILD_CMD);
    expect(swapSlotCalls).toContain('b');
    expect(rmCalls.some(([p]) =>
      normalizePathString(p).endsWith('/packages/node-ui/dist-ui'),
    )).toBe(true);
    expect(existsSyncCalls.some(([p]) =>
      normalizePathString(p).endsWith('/packages/node-ui/dist-ui/index.html')
    )).toBe(true);
  });

  it('falls back to build:runtime when the target lacks the runtime-only package script', async () => {
    const current = 'aaa111';
    const latest = 'bbb224-runtime-wrapper';
    mockGitUpdateReadFile(current, '9.0.0', '@origintrail-official/dkg-node-ui', {
      'build:runtime': RUNTIME_BUILD_CMD,
    });
    makeFetchOk(latest);

    const result = await performUpdate(AU, () => {});
    expect(result).toBe(true);

    const targetDir = '/tmp/dkg-test/releases/b';
    const allCmds = getExecCalls().filter(c => c.cwd === targetDir).map(c => c.cmd);
    expect(allCmds).toContain(RUNTIME_BUILD_CMD);
    expect(allCmds).not.toContain(RUNTIME_PACKAGES_BUILD_CMD);
  });

  it('uses the Node UI workspace package name from the target slot', async () => {
    const current = 'aaa111';
    const latest = 'bbb225';
    mockGitUpdateReadFile(current, '9.0.0-beta.2', '@dkg/node-ui');
    makeFetchOk(latest);
    let nodeUiBuilt = false;
    existsSyncImpl = (p: any) => {
      const path = normalizePathString(p);
      if (path.endsWith('/packages/node-ui/dist-ui/index.html')) return nodeUiBuilt;
      return true;
    };
    execImpl = async (cmd: string) => {
      if (cmd === LEGACY_NODE_UI_BUILD_CMD) nodeUiBuilt = true;
      return { stdout: '', stderr: '' };
    };

    const result = await performUpdate(AU, () => {});
    expect(result).toBe(true);

    const targetDir = '/tmp/dkg-test/releases/b';
    const allCmds = getExecCalls().filter(c => c.cwd === targetDir).map(c => c.cmd);
    expect(allCmds).toContain(LEGACY_NODE_UI_BUILD_CMD);
    expect(allCmds).not.toContain(NODE_UI_BUILD_CMD);
    expect(swapSlotCalls).toContain('b');
  });

  it('continues the update when MarkItDown staging fails inside the best-effort git-update step', async () => {
    const current = 'aaa111';
    const latest = 'bbb223';
    readFileImpl = async () => current;
    makeFetchOk(latest);
    execImpl = async (cmd: string) => {
      if (String(cmd).includes('bundle-markitdown-binaries.mjs')) {
        throw new Error('markitdown staging spawn failed');
      }
      return { stdout: '', stderr: '' };
    };

    const logCalls: string[] = [];
    const log = (msg: string) => { logCalls.push(msg); };
    const result = await performUpdate(AU, log);
    expect(result).toBe(true);
    expect(swapSlotCalls).toContain('b');
    expect(logCalls.some(m => m.includes('MarkItDown staging failed in slot b'))).toBe(true);
    expect(logCalls.some(m => m.includes('Continuing without document conversion'))).toBe(true);
  });

  it('swaps symlink after successful build', async () => {
    const current = 'aaa111';
    const latest = 'ccc333';
    readFileImpl = async () => current;
    makeFetchOk(latest);

    await performUpdate(AU, () => {});

    expect(swapSlotCalls).toContain('b');
    expect(
      writeFileCalls.some((call) =>
        normalizePathString(call[0]).includes('/tmp/dkg-test/.current-commit') && call[1] === latest)
    ).toBe(true);
  });

  it('returns true after swap via checkForUpdate', async () => {
    const current = 'aaa111';
    const latest = 'ddd444';
    readFileImpl = async () => current;
    makeFetchOk(latest);

    const updated = await checkForUpdate(AU, () => {});
    expect(updated).toBe(true);
  });

  it('build failure does not swap', async () => {
    const current = 'aaa111';
    const latest = 'eee555';
    readFileImpl = async () => current;
    makeFetchOk(latest);
    execImpl = async (cmd: string) => {
      if (String(cmd).includes('pnpm build')) throw new Error('build exploded');
      return { stdout: '', stderr: '' };
    };

    const logCalls: string[] = [];
    const log = (msg: string) => { logCalls.push(msg); };
    const result = await performUpdate(AU, log);
    expect(result).toBe(false);
    expect(swapSlotCalls.length).toBe(0);
    expect(logCalls.some(m => m.includes('build failed'))).toBe(true);
  });

  it('build failure does not touch active slot', async () => {
    const current = 'aaa111';
    const latest = 'fff666';
    readFileImpl = async () => current;
    makeFetchOk(latest);
    execImpl = async (cmd: string) => {
      if (String(cmd).includes('pnpm build')) throw new Error('build exploded');
      return { stdout: '', stderr: '' };
    };

    await performUpdate(AU, () => {});

    const allCwds = getExecCalls().map(c => c.cwd).filter(Boolean);
    const activeDir = '/tmp/dkg-test/releases/a';
    expect(allCwds.every((cwd: string) => cwd !== activeDir)).toBe(true);
  });

  it('fetch failure does not attempt build', async () => {
    const current = 'aaa111';
    const latest = 'ggg777';
    readFileImpl = async () => current;
    makeFetchOk(latest);
    execFileImpl = async (file: string, args: string[]) => {
      if (file === 'git' && args[0] === 'fetch') throw new Error('network down');
      return { stdout: '', stderr: '' };
    };

    const logCalls: string[] = [];
    const log = (msg: string) => { logCalls.push(msg); };
    const result = await performUpdate(AU, log);
    expect(result).toBe(false);

    const allCmds = getExecCalls().map(c => c.cmd);
    expect(allCmds.some(c => c.includes('pnpm install'))).toBe(false);
    expect(allCmds.some(c => c.includes('pnpm build'))).toBe(false);
  });

  it('slot alternation — consecutive updates build in alternating slots', async () => {
    mockActiveSlot = 'a';
    readFileImpl = async () => 'commit1';
    makeFetchOk('commit2');

    await performUpdate(AU, () => {});
    const firstBuildCwds = getExecCalls().map(c => c.cwd).filter(Boolean);
    expect(firstBuildCwds.some((cwd: string) => cwd.includes('/b'))).toBe(true);

    // Reset for second update
    resetMocks();
    mockActiveSlot = 'b';
    installMocks();
    readFileImpl = async () => 'commit2';
    makeFetchOk('commit3');

    await performUpdate(AU, () => {});
    const secondBuildCwds = getExecCalls().map(c => c.cwd).filter(Boolean);
    expect(secondBuildCwds.some((cwd: string) => cwd.includes('/a'))).toBe(true);
  });

  it('rejects branch names with shell injection characters', async () => {
    readFileImpl = async () => 'aaa111';
    const logCalls: string[] = [];
    const log = (msg: string) => { logCalls.push(msg); };

    const malicious: AutoUpdateConfig = {
      ...AU,
      branch: 'main; rm -rf /',
    };
    const result = await performUpdate(malicious, log);
    expect(result).toBe(false);
    expect(logCalls.some(m => m.includes('invalid branch'))).toBe(true);
    expect(execCalls.length).toBe(0);
    expect(execFileCalls.length).toBe(0);
  });

  it('aborts swap when build output (cli.js) is missing', async () => {
    readFileImpl = async () => 'aaa111';
    makeFetchOk('newcommit');

    existsSyncImpl = (p: any) => {
      const path = normalizePathString(p);
      if (path.includes('cli.js')) return false;
      return true;
    };

    const logCalls: string[] = [];
    const log = (msg: string) => { logCalls.push(msg); };
    const result = await performUpdate(AU, log);
    expect(result).toBe(false);
    expect(swapSlotCalls.length).toBe(0);
    expect(logCalls.some(m => m.includes('build output missing'))).toBe(true);
  });

  it('aborts swap when the git Node UI static bundle is missing after build', async () => {
    readFileImpl = async () => 'aaa111';
    makeFetchOk('newcommit');

    existsSyncImpl = (p: any) => {
      const path = normalizePathString(p);
      if (path.endsWith('/packages/node-ui/dist-ui/index.html')) return false;
      return true;
    };

    const logCalls: string[] = [];
    const log = (msg: string) => { logCalls.push(msg); };
    const result = await performUpdate(AU, log);
    expect(result).toBe(false);
    expect(swapSlotCalls.length).toBe(0);
    expect(logCalls.some(m => m.includes('Node UI static bundle missing'))).toBe(true);
  });

  it('does not swap when the Node UI static build fails', async () => {
    readFileImpl = async () => 'aaa111';
    makeFetchOk('newcommit');
    existsSyncImpl = (p: any) => {
      const path = normalizePathString(p);
      if (path.endsWith('/packages/node-ui/dist-ui/index.html')) return false;
      return true;
    };
    execImpl = async (cmd: string) => {
      if (cmd === NODE_UI_BUILD_CMD) throw new Error('vite exploded');
      return { stdout: '', stderr: '' };
    };

    const logCalls: string[] = [];
    const log = (msg: string) => { logCalls.push(msg); };
    const result = await performUpdate(AU, log);
    expect(result).toBe(false);
    expect(swapSlotCalls.length).toBe(0);
    expect(logCalls.some(m => m.includes('build failed') && m.includes('vite exploded'))).toBe(true);
  });

  it('continues the swap when the bundled MarkItDown binary is missing after build', async () => {
    readFileImpl = async () => 'aaa111';
    makeFetchOk('newcommit');

    existsSyncImpl = (p: any) => {
      const path = normalizePathString(p);
      if (path.includes('markitdown-')) return false;
      return true;
    };

    const logCalls: string[] = [];
    const log = (msg: string) => { logCalls.push(msg); };
    const result = await performUpdate(AU, log);
    expect(result).toBe(true);
    expect(swapSlotCalls.length).toBeGreaterThanOrEqual(1);
    expect(logCalls.some(m => m.includes('Continuing without document conversion'))).toBe(true);
  });

  it('reuses the active-slot MarkItDown binary when staging misses it during git update', async () => {
    const sourceBytes = Buffer.from('active-slot-markitdown', 'utf-8');
    const sourceHash = sha256HexForTest(sourceBytes);
    readFileImpl = async (path: any) => {
      const normalized = normalizePathString(path);
      if (normalized.endsWith('.current-commit')) return 'aaa111';
      if (normalized.includes('/releases/a/packages/cli/bin/markitdown-') && normalized.endsWith('.meta.json')) {
        return JSON.stringify({ source: 'build', cliVersion: CLI_VERSION, buildFingerprint: buildFingerprintForTest() });
      }
      if (normalized.includes('/releases/a/packages/cli/bin/markitdown-') && normalized.endsWith('.sha256')) {
        const assetName = normalized.split('/').pop()?.replace(/\.sha256$/, '') ?? 'markitdown-test';
        return `${sourceHash}  ${assetName}\n`;
      }
      if (normalized.includes('/releases/a/packages/cli/bin/markitdown-')) return sourceBytes;
      throw new Error(`Unexpected readFile path: ${normalized}`);
    };
    makeFetchOk('newcommit');

    existsSyncImpl = (p: any) => {
      const path = normalizePathString(p);
      if (path.includes('/releases/a/packages/cli/bin/markitdown-')) return true;
      if (path.includes('/releases/b/packages/cli/bin/markitdown-')) return false;
      return true;
    };
    _autoUpdateIo.hasVerifiedBundledMarkItDownBinary = async (binPath: string, metadata?: any) => {
      const normalized = normalizePathString(binPath);
      if (!existsSyncImpl(normalized)) return false;
      if (!existsSyncImpl(normalized + '.sha256')) return false;
      if (!metadata) return true;
      try {
        const metaRaw = await readFileImpl(normalized + '.meta.json');
        const meta = JSON.parse(metaRaw);
        if (metadata.cliVersion && meta.cliVersion !== metadata.cliVersion && !meta.buildFingerprint) return false;
        if (metadata.buildFingerprint && meta.buildFingerprint !== metadata.buildFingerprint) return false;
        return true;
      } catch { return false; }
    };
    _autoUpdateIo.expectedBundledMarkItDownBuildMetadata = () => ({ cliVersion: CLI_VERSION, buildFingerprint: buildFingerprintForTest() });

    const logCalls: string[] = [];
    const log = (msg: string) => { logCalls.push(msg); };
    const result = await performUpdate(AU, log);
    expect(result).toBe(true);
    expect(copyFileCalls.length).toBeGreaterThanOrEqual(1);
    expect(chmodCalls.length).toBeGreaterThanOrEqual(1);
    expect(logCalls.some(m => m.includes('reused bundled MarkItDown binary from the active slot'))).toBe(true);
  });

  it('continues the update when active-slot MarkItDown reuse copy fails', async () => {
    const sourceBytes = Buffer.from('active-slot-markitdown', 'utf-8');
    const sourceHash = sha256HexForTest(sourceBytes);
    readFileImpl = async (path: any) => {
      const normalized = normalizePathString(path);
      if (normalized.endsWith('.current-commit')) return 'aaa111';
      if (normalized.includes('/releases/a/packages/cli/bin/markitdown-') && normalized.endsWith('.meta.json')) {
        return JSON.stringify({ source: 'build', cliVersion: CLI_VERSION, buildFingerprint: buildFingerprintForTest() });
      }
      if (normalized.includes('/releases/a/packages/cli/bin/markitdown-') && normalized.endsWith('.sha256')) {
        const assetName = normalized.split('/').pop()?.replace(/\.sha256$/, '') ?? 'markitdown-test';
        return `${sourceHash}  ${assetName}\n`;
      }
      if (normalized.includes('/releases/a/packages/cli/bin/markitdown-')) return sourceBytes;
      throw new Error(`Unexpected readFile path: ${normalized}`);
    };
    makeFetchOk('newcommit');
    _autoUpdateIo.copyFile = (async () => { copyFileCalls.push([]); throw new Error('disk full'); }) as any;

    existsSyncImpl = (p: any) => {
      const path = normalizePathString(p);
      if (path.includes('/releases/a/packages/cli/bin/markitdown-')) return true;
      if (path.includes('/releases/b/packages/cli/bin/markitdown-')) return false;
      return true;
    };
    _autoUpdateIo.hasVerifiedBundledMarkItDownBinary = async (binPath: string, metadata?: any) => {
      const normalized = normalizePathString(binPath);
      if (!existsSyncImpl(normalized)) return false;
      if (!existsSyncImpl(normalized + '.sha256')) return false;
      if (!metadata) return true;
      try {
        const metaRaw = await readFileImpl(normalized + '.meta.json');
        const meta = JSON.parse(metaRaw);
        if (metadata.cliVersion && meta.cliVersion !== metadata.cliVersion && !meta.buildFingerprint) return false;
        if (metadata.buildFingerprint && meta.buildFingerprint !== metadata.buildFingerprint) return false;
        return true;
      } catch { return false; }
    };
    _autoUpdateIo.expectedBundledMarkItDownBuildMetadata = () => ({ cliVersion: CLI_VERSION, buildFingerprint: buildFingerprintForTest() });

    const logCalls: string[] = [];
    const log = (msg: string) => { logCalls.push(msg); };
    const result = await performUpdate(AU, log);
    expect(result).toBe(true);
    expect(logCalls.some(m => m.includes('failed to reuse bundled MarkItDown binary from the active slot'))).toBe(true);
    expect(logCalls.some(m => m.includes('Continuing without document conversion'))).toBe(true);
  });

  it('self-heals when target slot has no .git directory (empty dir from failed migration)', async () => {
    existsSyncImpl = (p: any) => {
      const path = normalizePathString(p);
      if (path.endsWith('/releases/a')) return true;
      if (path.endsWith('/releases/b/.git')) return false;
      if (path.includes('cli.js')) return true;
      return true;
    };
    readFileImpl = async () => 'aaa111';
    makeFetchOk('bbb222');

    const result = await performUpdate(AU, () => {});
    expect(result).toBe(true);
    const targetDir = '/tmp/dkg-test/releases/b';
    const gitCmds = getExecFileCalls();
    expect(gitCmds.some(c => c.file === 'git' && c.args.join(' ') === 'init' && c.cwd === targetDir)).toBe(true);
  });

  it('commit file is written before swap (crash safety)', async () => {
    readFileImpl = async () => 'old-commit';
    makeFetchOk('new-commit');

    const callOrder: string[] = [];
    writeFileImpl = async (path: any) => {
      const p = String(path);
      if (p.includes('.update-pending.json')) callOrder.push('writePending');
      else if (p.includes('.current-commit')) callOrder.push('writeCommit');
    };
    swapSlotImpl = async () => { callOrder.push('swapSlot'); };

    await performUpdate(AU, () => {});

    const writeIdx = callOrder.indexOf('writePending');
    const swapIdx = callOrder.indexOf('swapSlot');
    expect(writeIdx).toBeGreaterThanOrEqual(0);
    expect(swapIdx).toBeGreaterThan(writeIdx);
  });

  it('clears pending file if swap fails', async () => {
    const oldCommit = 'old-sha-111';
    const newCommit = 'new-sha-222';
    readFileImpl = async () => oldCommit;
    makeFetchOk(newCommit);

    swapSlotImpl = async () => { throw new Error('symlink failed'); };

    const logCalls: string[] = [];
    const log = (msg: string) => { logCalls.push(msg); };
    const result = await performUpdate(AU, log);
    expect(result).toBe(false);
    expect(logCalls.some(m => m.includes('symlink swap failed'))).toBe(true);
    const commitWrites = writeFileCalls.filter((c) => String(c[0]).includes('.current-commit'));
    expect(commitWrites.length).toBe(0);
  });

  it('checkForNewCommit is read-only — does not build, swap, or modify files', async () => {
    const { checkForNewCommit } = await import('../src/daemon.js');
    readFileImpl = async () => 'current-sha';
    makeFetchOk('new-sha');

    const logCalls: string[] = [];
    const log = (msg: string) => { logCalls.push(msg); };
    const result = await checkForNewCommit(AU, log);

    expect(result).toBe('new-sha');
    expect(execCalls.length).toBe(0);
    expect(swapSlotCalls.length).toBe(0);
    expect(writeFileCalls.length).toBe(0);
  });

  it('checkForNewCommit supports non-GitHub repos via git ls-remote', async () => {
    const { checkForNewCommit } = await import('../src/daemon.js');
    readFileImpl = async () => 'current-sha';
    execFileImpl = async (file: string, args: string[]) => {
      if (file === 'git' && args[0] === 'ls-remote') {
        return { stdout: 'abcdef1234567890abcdef1234567890abcdef12\trefs/heads/main\n', stderr: '' };
      }
      return { stdout: '', stderr: '' };
    };
    const logCalls: string[] = [];
    const log = (msg: string) => { logCalls.push(msg); };

    const result = await checkForNewCommit({ ...AU, repo: 'ssh://git.example.com/non-github.git' }, log);

    expect(result).toBe('abcdef1234567890abcdef1234567890abcdef12');
    expect(fetchCalls.length).toBe(0);
    expect(logCalls.every(m => !m.includes('failed to check for new commit'))).toBe(true);
  });

  it('checkForNewCommit also validates branch names', async () => {
    const { checkForNewCommit } = await import('../src/daemon.js');
    readFileImpl = async () => 'current-sha';
    const logCalls: string[] = [];
    const log = (msg: string) => { logCalls.push(msg); };

    const result = await checkForNewCommit({
      ...AU,
      branch: '$(whoami)',
    }, log);

    expect(result).toBeNull();
    expect(logCalls.some(m => m.includes('invalid branch'))).toBe(true);
  });

  it('rejects unsafe repo specs that start with dash', async () => {
    readFileImpl = async () => 'aaa111';
    makeFetchOk('bbb222');
    const logCalls: string[] = [];
    const log = (msg: string) => { logCalls.push(msg); };

    const result = await performUpdate(
      { ...AU, repo: '-c protocol.file.allow=always' },
      log,
    );

    expect(result).toBe(false);
    expect(logCalls.some(m => m.includes('invalid autoUpdate.repo'))).toBe(true);
    expect(execFileCalls.some(c => c.file === 'git' && c.args[0] === 'fetch')).toBe(false);
  });

  it('rejects refs that start with dash to avoid git option injection', async () => {
    const { checkForNewCommit } = await import('../src/daemon.js');
    readFileImpl = async () => 'current-sha';
    const logCalls: string[] = [];
    const log = (msg: string) => { logCalls.push(msg); };

    const result = await checkForNewCommit({
      ...AU,
      branch: '--upload-pack=/tmp/pwn',
    }, log);

    expect(result).toBeNull();
    expect(logCalls.some(m => m.includes('invalid branch/ref'))).toBe(true);
  });

  it('checkForNewCommit handles fetch/network errors without throwing', async () => {
    const { checkForNewCommit } = await import('../src/daemon.js');
    readFileImpl = async () => 'current-sha';
    fetchImpl = async () => { throw new Error('network timeout'); };
    const logCalls: string[] = [];
    const log = (msg: string) => { logCalls.push(msg); };

    const result = await checkForNewCommit(AU, log);

    expect(result).toBeNull();
    expect(logCalls.some(m => m.includes('failed to check for new commit'))).toBe(true);
  });

  it('supports explicit tag refs for version-targeted updates', async () => {
    readFileImpl = async () => 'aaa111';
    makeFetchOk('tagsha123');

    const result = await performUpdate(AU, () => {}, { refOverride: 'refs/tags/v9.0.5', verifyTagSignature: true });
    expect(result).toBe(true);
    const fetchUrl = String(fetchCalls[0]?.[0] ?? '');
    expect(fetchUrl).toContain('/commits/v9.0.5');
    expect(fetchUrl).not.toContain('refs%2Ftags%2Fv9.0.5');
    const allGitCalls = getExecFileCalls();
    expect(allGitCalls.some((c) => c.file === 'git' && c.args.join(' ') === 'fetch https://github.com/owner/repo.git refs/tags/v9.0.5:refs/tags/v9.0.5')).toBe(true);
    expect(allGitCalls.some((c) => c.file === 'git' && c.args.join(' ') === 'verify-tag v9.0.5')).toBe(true);
    expect(allGitCalls.some((c) => c.file === 'git' && c.args.join(' ') === 'checkout --force FETCH_HEAD')).toBe(true);
  });

  it('accepts refs containing build metadata (+) for tag checks', async () => {
    const { checkForNewCommit } = await import('../src/daemon.js');
    readFileImpl = async () => 'current-sha';
    makeFetchOk('new-sha');
    const logCalls: string[] = [];
    const log = (msg: string) => { logCalls.push(msg); };

    const result = await checkForNewCommit(
      { ...AU, branch: 'refs/tags/v1.2.3+build.5' },
      log,
    );

    expect(result).toBe('new-sha');
    expect(logCalls.every(m => !m.includes('invalid branch/ref'))).toBe(true);
  });

  it('logs clear error when requested tag does not exist', async () => {
    readFileImpl = async () => 'aaa111';
    fetchImpl = async () => ({ ok: false, status: 422 });
    const logCalls: string[] = [];
    const log = (msg: string) => { logCalls.push(msg); };

    const result = await performUpdate(AU, log, { refOverride: 'refs/tags/v9.0.5' });

    expect(result).toBe(false);
    expect(logCalls.some(m => m.includes('tag "v9.0.5" not found'))).toBe(true);
  });

  it('blocks pre-release versions unless allowPrerelease is true', async () => {
    readFileImpl = async (path: any) => {
      const p = normalizePathString(path);
      if (p.endsWith('.current-commit')) return 'aaa111';
      if (p.endsWith('.update-pending.json')) throw new Error('ENOENT');
      if (p.endsWith('/packages/cli/package.json')) return JSON.stringify({ version: '9.0.5-rc.1' });
      throw new Error(`Unexpected readFile path: ${p}`);
    };
    makeFetchOk('rcsha123');

    const logCalls: string[] = [];
    const log = (msg: string) => { logCalls.push(msg); };
    const result = await performUpdate({ ...AU, allowPrerelease: false }, log);
    expect(result).toBe(false);
    expect(logCalls.some(m => m.includes('pre-release'))).toBe(true);
    expect(swapSlotCalls.length).toBe(0);
  });

  it('allows pre-release versions when allowPrerelease=true', async () => {
    readFileImpl = async (path: any) => {
      const p = normalizePathString(path);
      if (p.endsWith('.current-commit')) return 'aaa111';
      if (p.endsWith('.update-pending.json')) throw new Error('ENOENT');
      if (p.endsWith('/packages/cli/package.json')) return JSON.stringify({ version: '9.0.5-rc.1' });
      throw new Error(`Unexpected readFile path: ${p}`);
    };
    makeFetchOk('rcsha999');

    const result = await performUpdate({ ...AU, allowPrerelease: true }, () => {});
    expect(result).toBe(true);
    expect(swapSlotCalls.length).toBeGreaterThanOrEqual(1);
  });
});

import { afterEach } from 'vitest';

describe('checkForNpmVersionUpdate tag precedence', () => {
  function makeRegistryResponse(distTags: Record<string, string>) {
    return {
      ok: true,
      json: async () => ({ 'dist-tags': distTags }),
    } as any;
  }

  beforeEach(async () => {
    resetMocks();
    installMocks();
    readFileImpl = async (path: any) => {
      const p = String(path);
      if (p.endsWith('.current-version')) return '9.0.0-beta.3';
      throw new Error('ENOENT');
    };
  });

  afterEach(() => {
    restoreIo();
  });

  it('uses latest tag when allowPrerelease=false and latest is stable', async () => {
    const { checkForNpmVersionUpdate } = await import('../src/daemon.js');
    fetchImpl = async () => makeRegistryResponse({
      latest: '9.1.0',
      dev: '9.2.0-dev.123.abc1234',
    });
    const result = await checkForNpmVersionUpdate(() => {}, false);
    expect(result.status).toBe('available');
    expect(result.version).toBe('9.1.0');
  });

  it('skips when allowPrerelease=false and latest is a prerelease', async () => {
    const { checkForNpmVersionUpdate } = await import('../src/daemon.js');
    fetchImpl = async () => makeRegistryResponse({
      latest: '9.1.0-beta.1',
      dev: '9.2.0-dev.123.abc1234',
    });
    const result = await checkForNpmVersionUpdate(() => {}, false);
    expect(result.status).toBe('up-to-date');
  });

  it('picks highest version across dev/latest/beta when allowPrerelease=true', async () => {
    const { checkForNpmVersionUpdate } = await import('../src/daemon.js');
    fetchImpl = async () => makeRegistryResponse({
      latest: '9.0.0-beta.4',
      dev: '9.0.0-beta.4-dev.999.abc1234',
      beta: '9.0.0-beta.3',
    });
    const result = await checkForNpmVersionUpdate(() => {}, true);
    expect(result.status).toBe('available');
    expect(result.version).toBe('9.0.0-beta.4-dev.999.abc1234');
  });

  it('prefers stable latest over older dev tag when allowPrerelease=true', async () => {
    const { checkForNpmVersionUpdate } = await import('../src/daemon.js');
    fetchImpl = async () => makeRegistryResponse({
      latest: '9.1.0',
      dev: '9.0.0-beta.4-dev.123.abc1234',
    });
    const result = await checkForNpmVersionUpdate(() => {}, true);
    expect(result.status).toBe('available');
    expect(result.version).toBe('9.1.0');
  });

  it('returns error on registry failure', async () => {
    const { checkForNpmVersionUpdate } = await import('../src/daemon.js');
    fetchImpl = async () => ({ ok: false, status: 503 });
    const result = await checkForNpmVersionUpdate(() => {}, true);
    expect(result.status).toBe('error');
  });

  it('returns up-to-date when current version matches latest', async () => {
    const { checkForNpmVersionUpdate } = await import('../src/daemon.js');
    fetchImpl = async () => makeRegistryResponse({
      latest: '9.0.0-beta.3',
    });
    const result = await checkForNpmVersionUpdate(() => {}, true);
    expect(result.status).toBe('up-to-date');
  });

  // Channel pinning (OT-RFC-41): a node tracks ONE dist-tag and is never
  // captured by whatever `latest` happens to point at. This is what keeps a
  // testnet cohort on the `testnet` tag after `latest` is repurposed for mainnet.
  it('channel pin follows ONLY that dist-tag, ignoring a higher latest', async () => {
    const { checkForNpmVersionUpdate } = await import('../src/daemon.js');
    fetchImpl = async () => makeRegistryResponse({
      latest: '9.5.0',          // another release line — MUST be ignored
      testnet: '9.0.0-beta.5',  // the pinned channel
    });
    const result = await checkForNpmVersionUpdate(() => {}, true, 'testnet');
    expect(result.status).toBe('available');
    expect(result.version).toBe('9.0.0-beta.5');
  });

  it('channel pin is up-to-date when the pinned tag does not advance', async () => {
    const { checkForNpmVersionUpdate } = await import('../src/daemon.js');
    fetchImpl = async () => makeRegistryResponse({
      latest: '9.5.0',
      testnet: '9.0.0-beta.3', // == current version
    });
    const result = await checkForNpmVersionUpdate(() => {}, true, 'testnet');
    expect(result.status).toBe('up-to-date');
  });

  it('channel pin reports no-target (not up-to-date) when the dist-tag does not exist yet', async () => {
    const { checkForNpmVersionUpdate } = await import('../src/daemon.js');
    fetchImpl = async () => makeRegistryResponse({ latest: '9.5.0' }); // no `testnet`
    const result = await checkForNpmVersionUpdate(() => {}, true, 'testnet');
    // Distinct from up-to-date: an unpublished/misconfigured channel must be
    // visible, not silently reported as current.
    expect(result.status).toBe('no-target');
    expect(result.channel).toBe('testnet');
  });

  it('channel pin reports no-target when allowPrerelease=false rejects a prerelease tag', async () => {
    const { checkForNpmVersionUpdate } = await import('../src/daemon.js');
    fetchImpl = async () => makeRegistryResponse({ testnet: '9.6.0-rc.1' });
    const result = await checkForNpmVersionUpdate(() => {}, false, 'testnet');
    expect(result.status).toBe('no-target');
    expect(result.channel).toBe('testnet');
  });

  it('channel pin reports no-target when the tag value is not valid semver', async () => {
    const { checkForNpmVersionUpdate } = await import('../src/daemon.js');
    fetchImpl = async () => makeRegistryResponse({ testnet: 'not-a-version' });
    const result = await checkForNpmVersionUpdate(() => {}, true, 'testnet');
    expect(result.status).toBe('no-target');
  });

  it('channel pin rejects a semver-LIKE-but-invalid tag (empty prerelease identifier)', async () => {
    const { checkForNpmVersionUpdate } = await import('../src/daemon.js');
    fetchImpl = async () => makeRegistryResponse({ testnet: '10.0.0-alpha..1' });
    const result = await checkForNpmVersionUpdate(() => {}, true, 'testnet');
    expect(result.status).toBe('no-target');
  });

  it('allowPrerelease=false accepts a STABLE version with hyphenated build metadata', async () => {
    const { checkForNpmVersionUpdate } = await import('../src/daemon.js');
    // The hyphen is in +build metadata, not a prerelease — must NOT be rejected.
    fetchImpl = async () => makeRegistryResponse({ mainnet: '10.0.0+mainnet-build.1' });
    const result = await checkForNpmVersionUpdate(() => {}, false, 'mainnet');
    expect(result.status).toBe('available');
    expect(result.version).toBe('10.0.0+mainnet-build.1');
  });

  it('channel pin follows a stable tag under allowPrerelease=false', async () => {
    const { checkForNpmVersionUpdate } = await import('../src/daemon.js');
    fetchImpl = async () => makeRegistryResponse({
      latest: '9.0.0-beta.9',
      mainnet: '10.0.0',
    });
    const result = await checkForNpmVersionUpdate(() => {}, false, 'mainnet');
    expect(result.status).toBe('available');
    expect(result.version).toBe('10.0.0');
  });

  it('default path does NOT attempt an update to a non-semver latest', async () => {
    const { checkForNpmVersionUpdate } = await import('../src/daemon.js');
    fetchImpl = async () => makeRegistryResponse({ latest: 'garbage' });
    const result = await checkForNpmVersionUpdate(() => {}, true);
    expect(result.status).toBe('up-to-date'); // not 'available' on garbage
  });
});

describe('deriveUpdateCheckState (runCheck → /api/status mapping)', () => {
  it('no-target → upToDate:true + channelTargetMissing:true + cleared latestVersion (updateAvailable stays false)', async () => {
    const { deriveUpdateCheckState } = await import('../src/daemon.js');
    expect(deriveUpdateCheckState({ status: 'no-target', channel: 'mainnet' }))
      .toEqual({ upToDate: true, channelTargetMissing: true, latestVersion: '' });
  });

  it('available → upToDate:false + clears channelTargetMissing + carries latestVersion', async () => {
    const { deriveUpdateCheckState } = await import('../src/daemon.js');
    expect(deriveUpdateCheckState({ status: 'available', version: '10.1.0' }))
      .toEqual({ upToDate: false, channelTargetMissing: false, latestVersion: '10.1.0' });
  });

  it('up-to-date → upToDate:true, clears channelTargetMissing AND clears stale latestVersion', async () => {
    const { deriveUpdateCheckState } = await import('../src/daemon.js');
    expect(deriveUpdateCheckState({ status: 'up-to-date', version: 'should-be-ignored' }))
      .toEqual({ upToDate: true, channelTargetMissing: false, latestVersion: '' });
  });

  it('error → null (caller leaves prior state untouched)', async () => {
    const { deriveUpdateCheckState } = await import('../src/daemon.js');
    expect(deriveUpdateCheckState({ status: 'error' })).toBeNull();
  });
});

describe('performNpmUpdate', () => {
  beforeEach(() => {
    resetMocks();
    mockActiveSlot = 'a';
    mockBundledCliPackageVersion = CLI_VERSION;
    mockInstalledPackageVersion = '9.0.0-beta.4-dev.100.abc1234';
    installMocks();
    readFileImpl = async (path: any) => {
      const p = String(path);
      if (p.endsWith('.update-pending.json')) throw new Error('ENOENT');
      if (p.endsWith('package.json')) return JSON.stringify({ version: '9.0.0-beta.4-dev.100.abc1234' });
      throw new Error(`Unexpected readFile: ${p}`);
    };
  });

  afterEach(() => {
    restoreIo();
  });

  it('installs package and swaps slot on success', async () => {
    const result = await performNpmUpdate('9.0.0-beta.4-dev.100.abc1234', () => {});
    expect(result).toBe('updated');
    expect(swapSlotCalls).toContain('b');
    expect(writeFileCalls.some(c =>
      String(c[0]).includes('.current-version') && c[1] === '9.0.0-beta.4-dev.100.abc1234'
    )).toBe(true);
  });

  it('returns failed when npm install throws', async () => {
    execImpl = async () => { throw new Error('npm ERR! 404'); };
    const result = await performNpmUpdate('9.99.0', () => {});
    expect(result).toBe('failed');
    expect(swapSlotCalls.length).toBe(0);
  });

  it('returns failed when entry point missing after install', async () => {
    existsSyncImpl = (p: any) => {
      if (String(p).includes('cli.js')) return false;
      return true;
    };
    const result = await performNpmUpdate('9.0.0-beta.5', () => {});
    expect(result).toBe('failed');
    expect(swapSlotCalls.length).toBe(0);
  });

  it('returns failed when npm Node UI static bundle is missing after install', async () => {
    existsSyncImpl = (p: any) => {
      const path = normalizePathString(p);
      if (path.endsWith('/dist-ui/index.html')) return false;
      return true;
    };

    const logCalls: string[] = [];
    const log = (msg: string) => { logCalls.push(msg); };
    const result = await performNpmUpdate('9.0.0-beta.5', log);

    expect(result).toBe('failed');
    expect(swapSlotCalls.length).toBe(0);
    expect(logCalls.some(m => m.includes('Node UI static bundle missing'))).toBe(true);
    expect(existsSyncCalls.some(([path]) =>
      normalizePathString(path).endsWith('/packages/node-ui/dist-ui/index.html'),
    )).toBe(false);
  });

  it('does not accept a legacy npm UI bundle for a current-package npm update', async () => {
    readFileImpl = async (path: any) => {
      const normalized = normalizePathString(path);
      if (normalized.endsWith('.update-pending.json')) throw new Error('ENOENT');
      if (normalized.endsWith('/node_modules/@origintrail-official/dkg/package.json')) {
        return JSON.stringify({
          version: '9.0.0-beta.5',
          dependencies: { '@origintrail-official/dkg-node-ui': '9.0.0-beta.5' },
        });
      }
      if (normalized.endsWith('package.json')) return JSON.stringify({ version: '9.0.0-beta.5' });
      throw new Error(`Unexpected readFile: ${normalized}`);
    };
    existsSyncImpl = (p: any) => {
      const path = normalizePathString(p);
      if (path.endsWith('/node_modules/@origintrail-official/dkg/dist/cli.js')) return true;
      if (path.includes('/@dkg/node-ui/dist-ui/index.html')) return true;
      if (path.endsWith('/dist-ui/index.html')) return false;
      return true;
    };

    const result = await performNpmUpdate('9.0.0-beta.5', () => {});

    expect(result).toBe('failed');
    expect(swapSlotCalls.length).toBe(0);
    expect(existsSyncCalls.some(([path]) =>
      normalizePathString(path).includes('/@dkg/node-ui/dist-ui/index.html'),
    )).toBe(false);
  });

  it('continues when the bundled MarkItDown binary is missing after install', async () => {
    mockInstalledPackageVersion = '9.0.0-beta.5';
    existsSyncImpl = (p: any) => {
      const path = String(p);
      if (path.includes('markitdown-')) return false;
      return true;
    };
    const logCalls: string[] = [];
    const log = (msg: string) => { logCalls.push(msg); };
    const result = await performNpmUpdate('9.0.0-beta.5', log);
    expect(result).toBe('updated');
    expect(swapSlotCalls.length).toBeGreaterThanOrEqual(1);
    expect(logCalls.some(m => m.includes('Continuing without document conversion'))).toBe(true);
  });

  it('reuses the active-slot MarkItDown binary when npm install leaves it missing', async () => {
    mockInstalledPackageVersion = '9.0.0-beta.5';
    const sourceBytes = Buffer.from('active-slot-markitdown', 'utf-8');
    const sourceHash = sha256HexForTest(sourceBytes);
    readFileImpl = async (path: any) => {
      const normalized = normalizePathString(path);
      if (normalized.endsWith('.update-pending.json')) throw new Error('ENOENT');
      if (normalized.includes('/releases/a/node_modules/@origintrail-official/dkg/bin/markitdown-') && normalized.endsWith('.meta.json')) {
        return JSON.stringify({ source: 'release', cliVersion: '9.0.0-beta.5' });
      }
      if (normalized.includes('/releases/a/node_modules/@origintrail-official/dkg/bin/markitdown-') && normalized.endsWith('.sha256')) {
        const assetName = normalized.split('/').pop()?.replace(/\.sha256$/, '') ?? 'markitdown-test';
        return `${sourceHash}  ${assetName}\n`;
      }
      if (normalized.includes('/releases/a/node_modules/@origintrail-official/dkg/bin/markitdown-')) return sourceBytes;
      throw new Error(`Unexpected readFile: ${normalized}`);
    };
    existsSyncImpl = (p: any) => {
      const path = normalizePathString(p);
      if (path.includes('/releases/a/node_modules/@origintrail-official/dkg/bin/markitdown-')) return true;
      if (path.includes('/releases/b/node_modules/@origintrail-official/dkg/bin/markitdown-')) return false;
      return true;
    };
    _autoUpdateIo.hasVerifiedBundledMarkItDownBinary = async (binPath: string, metadata?: any) => {
      const normalized = normalizePathString(binPath);
      if (!existsSyncImpl(normalized)) return false;
      if (!existsSyncImpl(normalized + '.sha256')) return false;
      if (!metadata) return true;
      try {
        const metaRaw = await readFileImpl(normalized + '.meta.json');
        const meta = JSON.parse(metaRaw);
        if (metadata.cliVersion && meta.cliVersion !== metadata.cliVersion && !meta.buildFingerprint) return false;
        return true;
      } catch { return false; }
    };
    _autoUpdateIo.expectedBundledMarkItDownBuildMetadata = () => ({ cliVersion: '9.0.0-beta.5' });

    const logCalls: string[] = [];
    const log = (msg: string) => { logCalls.push(msg); };
    const result = await performNpmUpdate('9.0.0-beta.5', log);
    expect(result).toBe('updated');
    expect(copyFileCalls.length).toBeGreaterThanOrEqual(1);
    expect(chmodCalls.length).toBeGreaterThanOrEqual(1);
    expect(logCalls.some(m => m.includes('reused bundled MarkItDown binary from the active slot'))).toBe(true);
  });

  it('skips active-slot MarkItDown reuse when metadata targets a different npm version', async () => {
    mockInstalledPackageVersion = '9.0.0-beta.5';
    readFileImpl = async (path: any) => {
      const normalized = normalizePathString(path);
      if (normalized.endsWith('.update-pending.json')) throw new Error('ENOENT');
      if (normalized.includes('/releases/a/node_modules/@origintrail-official/dkg/bin/markitdown-') && normalized.endsWith('.meta.json')) {
        return JSON.stringify({ source: 'release', cliVersion: '9.0.0-beta.4' });
      }
      if (normalized.includes('/releases/a/node_modules/@origintrail-official/dkg/bin/markitdown-') && normalized.endsWith('.sha256')) {
        const assetName = normalized.split('/').pop()?.replace(/\.sha256$/, '') ?? 'markitdown-test';
        return `${sha256HexForTest(Buffer.from('active-slot-markitdown', 'utf-8'))}  ${assetName}\n`;
      }
      if (normalized.includes('/releases/a/node_modules/@origintrail-official/dkg/bin/markitdown-')) {
        return Buffer.from('active-slot-markitdown', 'utf-8');
      }
      throw new Error(`Unexpected readFile: ${normalized}`);
    };
    existsSyncImpl = (p: any) => {
      const path = normalizePathString(p);
      if (path.includes('/releases/a/node_modules/@origintrail-official/dkg/bin/markitdown-')) return true;
      if (path.includes('/releases/b/node_modules/@origintrail-official/dkg/bin/markitdown-')) return false;
      return true;
    };
    _autoUpdateIo.hasVerifiedBundledMarkItDownBinary = async (binPath: string, metadata?: any) => {
      const normalized = normalizePathString(binPath);
      if (!existsSyncImpl(normalized)) return false;
      if (!existsSyncImpl(normalized + '.sha256')) return false;
      if (!metadata) return true;
      try {
        const metaRaw = await readFileImpl(normalized + '.meta.json');
        const meta = JSON.parse(metaRaw);
        if (metadata.cliVersion && meta.cliVersion !== metadata.cliVersion && !meta.buildFingerprint) return false;
        return true;
      } catch { return false; }
    };
    _autoUpdateIo.expectedBundledMarkItDownBuildMetadata = () => ({ cliVersion: '9.0.0-beta.5' });

    const logCalls: string[] = [];
    const log = (msg: string) => { logCalls.push(msg); };
    const result = await performNpmUpdate('9.0.0-beta.5', log);
    expect(result).toBe('updated');
    expect(copyFileCalls.length).toBe(0);
    expect(logCalls.some(m => m.includes('incompatible metadata'))).toBe(true);
    expect(logCalls.some(m => m.includes('Continuing without document conversion'))).toBe(true);
  });

  it('skips active-slot MarkItDown reuse when the checksum sidecar is missing', async () => {
    mockInstalledPackageVersion = '9.0.0-beta.5';
    readFileImpl = async (path: any) => {
      const normalized = normalizePathString(path);
      if (normalized.endsWith('.update-pending.json')) throw new Error('ENOENT');
      throw new Error(`Unexpected readFile: ${normalized}`);
    };
    existsSyncImpl = (p: any) => {
      const path = normalizePathString(p);
      if (path.includes('/releases/a/node_modules/@origintrail-official/dkg/bin/markitdown-') && path.endsWith('.sha256')) return false;
      if (path.includes('/releases/a/node_modules/@origintrail-official/dkg/bin/markitdown-')) return true;
      if (path.includes('/releases/b/node_modules/@origintrail-official/dkg/bin/markitdown-')) return false;
      return true;
    };
    _autoUpdateIo.hasVerifiedBundledMarkItDownBinary = async (binPath: string) => {
      const normalized = normalizePathString(binPath);
      if (!existsSyncImpl(normalized)) return false;
      if (!existsSyncImpl(normalized + '.sha256')) return false;
      return true;
    };

    const logCalls: string[] = [];
    const log = (msg: string) => { logCalls.push(msg); };
    const result = await performNpmUpdate('9.0.0-beta.5', log);
    expect(result).toBe('updated');
    expect(copyFileCalls.length).toBe(0);
    expect(logCalls.some(m => m.includes('skipping active-slot bundled MarkItDown binary without a valid checksum sidecar'))).toBe(true);
    expect(logCalls.some(m => m.includes('Continuing without document conversion'))).toBe(true);
  });

  it('does not probe a source-slot build binary as an npm reuse candidate', async () => {
    mockInstalledPackageVersion = '9.0.0-beta.5';
    readFileImpl = async (path: any) => {
      const normalized = normalizePathString(path);
      if (normalized.endsWith('.update-pending.json')) throw new Error('ENOENT');
      if (normalized.includes('/releases/a/packages/cli/bin/markitdown-')) {
        throw new Error(`npm update should not inspect source-slot MarkItDown candidates: ${normalized}`);
      }
      throw new Error(`Unexpected readFile: ${normalized}`);
    };
    existsSyncImpl = (p: any) => {
      const path = normalizePathString(p);
      if (path.includes('/releases/a/packages/cli/bin/markitdown-')) return true;
      if (path.includes('/releases/a/node_modules/@origintrail-official/dkg/bin/markitdown-')) return false;
      if (path.includes('/releases/b/node_modules/@origintrail-official/dkg/bin/markitdown-')) return false;
      return true;
    };

    const logCalls: string[] = [];
    const log = (msg: string) => { logCalls.push(msg); };
    const result = await performNpmUpdate('9.0.0-beta.5', log);
    expect(result).toBe('updated');
    expect(copyFileCalls.length).toBe(0);
    expect(existsSyncCalls.some(
      ([path]) => normalizePathString(path).includes('/releases/a/packages/cli/bin/markitdown-'),
    )).toBe(false);
    expect(logCalls.some(m => m.includes('Continuing without document conversion'))).toBe(true);
  });

  it('validates npm-installed MarkItDown metadata against the resolved package version instead of the requested spec', async () => {
    mockInstalledPackageVersion = '9.0.0-beta.6';
    const sourceBytes = Buffer.from('active-slot-markitdown', 'utf-8');
    const sourceHash = sha256HexForTest(sourceBytes);
    readFileImpl = async (path: any) => {
      const normalized = normalizePathString(path);
      if (normalized.endsWith('.update-pending.json')) throw new Error('ENOENT');
      if (normalized.includes('/releases/a/node_modules/@origintrail-official/dkg/bin/markitdown-') && normalized.endsWith('.meta.json')) {
        return JSON.stringify({ source: 'release', cliVersion: '9.0.0-beta.6' });
      }
      if (normalized.includes('/releases/a/node_modules/@origintrail-official/dkg/bin/markitdown-') && normalized.endsWith('.sha256')) {
        const assetName = normalized.split('/').pop()?.replace(/\.sha256$/, '') ?? 'markitdown-test';
        return `${sourceHash}  ${assetName}\n`;
      }
      if (normalized.includes('/releases/a/node_modules/@origintrail-official/dkg/bin/markitdown-')) return sourceBytes;
      throw new Error(`Unexpected readFile: ${normalized}`);
    };
    existsSyncImpl = (p: any) => {
      const path = normalizePathString(p);
      if (path.includes('/releases/a/node_modules/@origintrail-official/dkg/bin/markitdown-')) return true;
      if (path.includes('/releases/b/node_modules/@origintrail-official/dkg/bin/markitdown-')) return false;
      return true;
    };
    _autoUpdateIo.hasVerifiedBundledMarkItDownBinary = async (binPath: string, metadata?: any) => {
      const normalized = normalizePathString(binPath);
      if (!existsSyncImpl(normalized)) return false;
      if (!existsSyncImpl(normalized + '.sha256')) return false;
      if (!metadata) return true;
      try {
        const metaRaw = await readFileImpl(normalized + '.meta.json');
        const meta = JSON.parse(metaRaw);
        if (metadata.cliVersion && meta.cliVersion !== metadata.cliVersion) return false;
        return true;
      } catch { return false; }
    };
    _autoUpdateIo.expectedBundledMarkItDownBuildMetadata = () => ({ cliVersion: '9.0.0-beta.6' });

    const logCalls: string[] = [];
    const log = (msg: string) => { logCalls.push(msg); };
    const result = await performNpmUpdate('latest', log);
    expect(result).toBe('updated');
    expect(copyFileCalls.length).toBeGreaterThanOrEqual(1);
    expect(writeFileCalls.some(c =>
      String(c[0]).includes('.current-version') && c[1] === '9.0.0-beta.6'
    )).toBe(true);
    expect(logCalls.some(m => m.includes('reused bundled MarkItDown binary from the active slot'))).toBe(true);
  });

  it('reuses a fingerprint-compatible active-slot MarkItDown binary across CLI version bumps', async () => {
    mockInstalledPackageVersion = '9.0.0-beta.6';
    const sourceBytes = Buffer.from('active-slot-markitdown', 'utf-8');
    const sourceHash = sha256HexForTest(sourceBytes);
    readFileImpl = async (path: any) => {
      const normalized = normalizePathString(path);
      if (normalized.endsWith('.update-pending.json')) throw new Error('ENOENT');
      if (normalized.includes('/releases/a/node_modules/@origintrail-official/dkg/bin/markitdown-') && normalized.endsWith('.meta.json')) {
        return JSON.stringify({
          source: 'release',
          cliVersion: '9.0.0-beta.5',
          buildFingerprint: buildFingerprintForTest(),
        });
      }
      if (normalized.includes('/releases/a/node_modules/@origintrail-official/dkg/bin/markitdown-') && normalized.endsWith('.sha256')) {
        const assetName = normalized.split('/').pop()?.replace(/\.sha256$/, '') ?? 'markitdown-test';
        return `${sourceHash}  ${assetName}\n`;
      }
      if (normalized.includes('/releases/a/node_modules/@origintrail-official/dkg/bin/markitdown-')) return sourceBytes;
      throw new Error(`Unexpected readFile: ${normalized}`);
    };
    existsSyncImpl = (p: any) => {
      const path = normalizePathString(p);
      if (path.includes('/releases/a/node_modules/@origintrail-official/dkg/bin/markitdown-')) return true;
      if (path.includes('/releases/b/node_modules/@origintrail-official/dkg/bin/markitdown-')) return false;
      return true;
    };
    _autoUpdateIo.hasVerifiedBundledMarkItDownBinary = async (binPath: string, metadata?: any) => {
      const normalized = normalizePathString(binPath);
      if (!existsSyncImpl(normalized)) return false;
      if (!existsSyncImpl(normalized + '.sha256')) return false;
      if (!metadata) return true;
      try {
        const metaRaw = await readFileImpl(normalized + '.meta.json');
        const meta = JSON.parse(metaRaw);
        if (metadata.buildFingerprint && meta.buildFingerprint === metadata.buildFingerprint) return true;
        if (metadata.cliVersion && meta.cliVersion !== metadata.cliVersion) return false;
        return true;
      } catch { return false; }
    };
    _autoUpdateIo.expectedBundledMarkItDownBuildMetadata = () => ({ cliVersion: '9.0.0-beta.6', buildFingerprint: buildFingerprintForTest() });

    const logCalls: string[] = [];
    const log = (msg: string) => { logCalls.push(msg); };
    const result = await performNpmUpdate('9.0.0-beta.6', log);
    expect(result).toBe('updated');
    expect(copyFileCalls.length).toBeGreaterThanOrEqual(1);
    expect(logCalls.some(m => m.includes('reused bundled MarkItDown binary from the active slot'))).toBe(true);
  });

  it('recovers pending state if swap succeeded but version was not written', async () => {
    mockActiveSlot = 'b';
    readFileImpl = async (path: any) => {
      const p = String(path);
      if (p.endsWith('.update-pending.json')) {
        return JSON.stringify({
          target: 'b',
          commit: '',
          version: '9.0.0-beta.4-dev.200.def5678',
          ref: 'npm:9.0.0-beta.4-dev.200.def5678',
          createdAt: new Date().toISOString(),
        });
      }
      if (p.endsWith('package.json')) return JSON.stringify({ version: '9.0.0-beta.4-dev.200.def5678' });
      throw new Error(`Unexpected readFile: ${p}`);
    };
    const logCalls: string[] = [];
    const log = (msg: string) => { logCalls.push(msg); };
    const result = await performNpmUpdate('9.0.0-beta.4-dev.200.def5678', log);
    expect(result).toBe('updated');
    expect(logCalls.some(m => m.includes('recovered pending'))).toBe(true);
    expect(writeFileCalls.some(c =>
      String(c[0]).includes('.current-version') && c[1] === '9.0.0-beta.4-dev.200.def5678'
    )).toBe(true);
    expect(swapSlotCalls.length).toBe(0);
  });

  it('returns failed when slot swap throws', async () => {
    swapSlotImpl = async () => { throw new Error('EPERM'); };
    const logCalls: string[] = [];
    const log = (msg: string) => { logCalls.push(msg); };
    const result = await performNpmUpdate('9.0.0-beta.5', log);
    expect(result).toBe('failed');
    expect(logCalls.some(m => m.includes('symlink swap failed'))).toBe(true);
  });
});

describe('compareSemver', () => {
  let compareSemver: (a: string, b: string) => number;
  beforeEach(async () => {
    ({ compareSemver } = await import('../src/daemon.js'));
  });

  it('stable > prerelease for the same version', () => {
    expect(compareSemver('9.0.0', '9.0.0-beta.1')).toBeGreaterThan(0);
    expect(compareSemver('9.0.0-beta.1', '9.0.0')).toBeLessThan(0);
  });

  it('higher prerelease > lower prerelease', () => {
    expect(compareSemver('9.0.0-beta.2', '9.0.0-beta.1')).toBeGreaterThan(0);
    expect(compareSemver('9.0.0-beta.1', '9.0.0-beta.2')).toBeLessThan(0);
  });

  it('ignores build metadata for ordering', () => {
    expect(compareSemver('1.2.3-alpha+1', '1.2.3-alpha+2')).toBe(0);
    expect(compareSemver('9.0.0+build.1', '9.0.0+build.2')).toBe(0);
  });

  it('handles dev suffix with numeric comparison', () => {
    expect(compareSemver('9.0.0-beta.4-dev.200', '9.0.0-beta.4-dev.100')).toBeGreaterThan(0);
  });

  it('equal versions return 0', () => {
    expect(compareSemver('9.0.0', '9.0.0')).toBe(0);
    expect(compareSemver('9.0.0-beta.1', '9.0.0-beta.1')).toBe(0);
  });

  it('major/minor/patch ordering', () => {
    expect(compareSemver('10.0.0', '9.99.99')).toBeGreaterThan(0);
    expect(compareSemver('9.1.0', '9.0.99')).toBeGreaterThan(0);
    expect(compareSemver('9.0.1', '9.0.0')).toBeGreaterThan(0);
  });

  it('strips v prefix', () => {
    expect(compareSemver('v9.0.0', '9.0.0')).toBe(0);
  });
});

// ─── Hardening: incremental builds, configurable timeouts, fail-safe contract diff,
//     persisted update status. See PR `autoupdater-hardening`.
describe('autoupdater hardening', () => {
  beforeEach(() => {
    resetMocks();
    mockActiveSlot = 'a';
    installMocks();
  });

  afterEach(() => {
    restoreIo();
  });

  it('does NOT run `git clean -fdx` by default — preserves node_modules/Hardhat cache for incremental rebuild', async () => {
    readFileImpl = async () => 'aaa111';
    makeFetchOk('bbb222');
    await performUpdate(AU, () => {});
    const cleanCall = getExecFileCalls().find(
      c => c.file === 'git' && c.args[0] === 'clean',
    );
    expect(cleanCall).toBeUndefined();
  });

  it('runs `git clean -fdx` only when forceClean=true is passed', async () => {
    readFileImpl = async () => 'aaa111';
    makeFetchOk('bbb222');
    await performUpdate(AU, () => {}, { forceClean: true });
    const cleanCall = getExecFileCalls().find(
      c => c.file === 'git' && c.args[0] === 'clean' && c.args[1] === '-fdx',
    );
    expect(cleanCall).toBeTruthy();
  });

  it('honours autoUpdate.buildTimeoutMs.install on pnpm install', async () => {
    readFileImpl = async () => 'aaa111';
    makeFetchOk('bbb222');
    let installTimeout: number | undefined;
    execImpl = async (cmd: string, opts?: any) => {
      if (cmd.includes('pnpm install')) installTimeout = opts?.timeout;
      return { stdout: '', stderr: '' };
    };
    const auWithTimeout: AutoUpdateConfig = {
      ...AU,
      buildTimeoutMs: { install: 600_000 },
    };
    await performUpdate(auWithTimeout as any, () => {});
    expect(installTimeout).toBe(600_000);
  });

  // ─── Contract build is OFF the node update path ─────────────────────
  //
  // The auto-updater intentionally never invokes `hardhat compile` on node
  // hosts. The committed `packages/evm-module/abi/*.json` files are the
  // runtime contract surface (consumed by `packages/chain` via require()),
  // and the `abi-freshness` CI job blocks any PR that changes `.sol`
  // sources without committing the regenerated ABIs. This removes the
  // most failure-prone step from the update flow — `hardhat compile`
  // routinely OOMs / times out on resource-constrained nodes (cold solc
  // on ARM64), and any failure there used to abort the slot swap.
  //
  // The previous tests in this slot exercised:
  //   - autoUpdate.buildTimeoutMs.contracts honouring (contracts no longer rebuilt)
  //   - contract-diff fails closed (no diff is performed)
  //   - hardhat clean ordering before rebuild (no rebuild)
  //   - contract-diff retry via `git fetch --depth=1` (no diff)
  //
  // Replaced with one explicit assertion: hardhat is never invoked on the
  // node update path, even when the diff between commits lists changed
  // .sol sources.
  it('never invokes hardhat / evm-module build on the auto-update path, even when contracts changed between commits', async () => {
    mockGitUpdateReadFile();
    makeFetchOk('bbb222');
    const evmCommands: string[] = [];
    execImpl = async (cmd: string) => {
      if (
        cmd.includes('@origintrail-official/dkg-evm-module') ||
        cmd.includes('hardhat')
      ) {
        evmCommands.push(cmd);
      }
      return { stdout: '', stderr: '' };
    };
    // Even if the diff would have shown a .sol change, the new
    // implementation must not consult it — and must not invoke any
    // evm-module / hardhat command. Stub diff anyway to make this test
    // a regression guard if the conditional is ever re-introduced.
    execFileImpl = async (file: string, args: string[]) => {
      if (file === 'git' && args[0] === 'diff') {
        return { stdout: 'packages/evm-module/contracts/Foo.sol\n', stderr: '' };
      }
      return { stdout: '', stderr: '' };
    };
    await performUpdate(AU, () => {});
    expect(evmCommands).toEqual([]);
  });

  it('atomic bookkeeping writes go through a temp path then rename to final', async () => {
    // Reproduces the dkg-v9-relay-01 corruption scenario: a partial / retried
    // writeFile to `.current-commit` left an 80-char doubled SHA on disk. With
    // writeFileAtomic, the actual writeFile lands at a tmp path and only the
    // rename produces the live file — so an interrupted write can never be
    // observed by the daemon as a corrupted live file.
    readFileImpl = async () => 'aaa111';
    makeFetchOk('bbb222');
    const renameCalls: Array<[string, string]> = [];
    _autoUpdateIo.rename = (async (from: any, to: any) => {
      renameCalls.push([String(from), String(to)]);
    }) as any;
    await performUpdate(AU, () => {});
    const commitRename = renameCalls.find(([, to]) => to.endsWith('/.current-commit'));
    expect(commitRename).toBeTruthy();
    expect(commitRename?.[0]).toMatch(/\.current-commit\.tmp\./);
  });

  it('self-heals pre-existing `.current-commit` corruption (>64 chars) by re-deriving from git HEAD', async () => {
    // Exact dkg-v9-relay-01 reproduction: the file on disk contained the same
    // 40-char SHA written twice end-to-end (80 chars total). The daemon should
    // detect the malformed value, fall back to `git rev-parse HEAD`, and on
    // the next swap rewrite the file (atomically) with the real SHA.
    const corrupted = 'a'.repeat(40) + 'a'.repeat(40); // 80 chars
    readFileImpl = async (path: any) => {
      const p = String(path);
      if (p.endsWith('.current-commit')) return corrupted;
      return '';
    };
    makeFetchOk('bbb222');
    let revParseCalled = false;
    execImpl = async (cmd: string) => {
      if (cmd.includes('git rev-parse HEAD')) {
        revParseCalled = true;
        return { stdout: 'aaa111\n', stderr: '' };
      }
      return { stdout: '', stderr: '' };
    };
    const logCalls: string[] = [];
    await performUpdate(AU, (m) => logCalls.push(m));
    expect(revParseCalled).toBe(true);
    expect(logCalls.some((m) => m.includes('malformed value') && m.includes('len=80'))).toBe(true);
  });

  // ─── Bot-review fixes (PR #303) ─────────────────────────────────────────

  it('clears stale dist/ + tsconfig.tsbuildinfo + cli/network/ + cli/project.json (preserves node_modules + Hardhat caches)', async () => {
    readFileImpl = async () => 'aaa111';
    makeFetchOk('bbb222');
    // Default readdir mock returns two packages: 'core' and 'cli'. Each must
    // get its `dist/` and `tsconfig.tsbuildinfo` rm'd. Additionally, the
    // packages/cli build script copies repo-root `network/*.json` into
    // `packages/cli/network/` and `project.json` into `packages/cli/project.json`,
    // so those must also be wiped — otherwise a deleted/renamed root network
    // config can survive in the inactive slot and be loaded via candidateRoots().
    await performUpdate(AU, () => {});
    const rmTargets = rmCalls.map(args => String(args[0]));
    const wipesDistCore = rmTargets.some(p => p.endsWith('/packages/core/dist'));
    const wipesDistCli = rmTargets.some(p => p.endsWith('/packages/cli/dist'));
    const wipesTsBuildInfoCore = rmTargets.some(p => p.endsWith('/packages/core/tsconfig.tsbuildinfo'));
    const wipesTsBuildInfoCli = rmTargets.some(p => p.endsWith('/packages/cli/tsconfig.tsbuildinfo'));
    const wipesCliNetworkDir = rmTargets.some(p => p.endsWith('/packages/cli/network'));
    const wipesCliProjectJson = rmTargets.some(p => p.endsWith('/packages/cli/project.json'));
    expect(wipesDistCore).toBe(true);
    expect(wipesDistCli).toBe(true);
    expect(wipesTsBuildInfoCore).toBe(true);
    expect(wipesTsBuildInfoCli).toBe(true);
    expect(wipesCliNetworkDir).toBe(true);
    expect(wipesCliProjectJson).toBe(true);
    // Sanity: no node_modules wipe and no Hardhat cache/artifacts wipe.
    const touchesNodeModules = rmTargets.some(p => p.includes('node_modules'));
    const touchesHardhatCache = rmTargets.some(p =>
      p.endsWith('/cache') || p.endsWith('/artifacts'),
    );
    expect(touchesNodeModules).toBe(false);
    expect(touchesHardhatCache).toBe(false);
    // Sanity: we did NOT shell out to `find` (the legacy implementation).
    const findCalls = getExecFileCalls().filter(c => c.file === 'find');
    expect(findCalls.length).toBe(0);
    // Regression: cli/network/ is rm'd recursively (so a stale per-network
    // file, e.g. `packages/cli/network/devnet.json` left behind after the
    // root `network/devnet.json` was deleted in a later commit, gets wiped
    // along with the directory). `force: true` makes the rm a no-op when
    // the dir is absent (fresh clone path).
    const cliNetworkRmCall = rmCalls.find(args => String(args[0]).endsWith('/packages/cli/network'));
    expect(cliNetworkRmCall).toBeDefined();
    expect(cliNetworkRmCall?.[1]).toMatchObject({ recursive: true, force: true });
    const cliProjectRmCall = rmCalls.find(args => String(args[0]).endsWith('/packages/cli/project.json'));
    expect(cliProjectRmCall).toBeDefined();
    expect(cliProjectRmCall?.[1]).toMatchObject({ force: true });
  });

  it('orphan-process sweep is scoped to the slot dir (no host-wide pkill -f)', async () => {
    readFileImpl = async () => 'aaa111';
    makeFetchOk('bbb222');
    const sweepCalls: Array<{ cmd: string; env: any }> = [];
    _autoUpdateIo.execSync = ((cmd: any, opts?: any) => {
      sweepCalls.push({ cmd: String(cmd), env: opts?.env ?? null });
      return '';
    }) as any;
    // Make pnpm install time out → triggers the sweep.
    execImpl = async (cmd: string) => {
      if (cmd.includes('pnpm install')) {
        const err: any = new Error('Command failed: pnpm install ETIMEDOUT');
        err.killed = true;
        err.signal = 'SIGTERM';
        throw err;
      }
      return { stdout: '', stderr: '' };
    };
    await performUpdate(AU, () => {});
    expect(sweepCalls.length).toBeGreaterThan(0);
    for (const call of sweepCalls) {
      // No host-wide command-line pattern matching.
      expect(call.cmd).not.toMatch(/pkill\s+(-\S+\s+)*-f/);
      // Scoping happens via env vars, not embedded in the script. EUID is
      // resolved in Node and passed as DKG_AU_UID — we MUST NOT depend on
      // bash-only `$EUID` because /bin/sh on Ubuntu/Debian is dash.
      expect(call.cmd).toContain('$DKG_AU_SLOT');
      expect(call.cmd).toContain('pgrep -u "$DKG_AU_UID"');
      expect(call.cmd).not.toContain('$EUID');
      expect(call.cmd).toContain('/proc/$pid/cwd');
      expect(call.env?.DKG_AU_SLOT).toMatch(/\/releases\/[ab]$/);
      expect(call.env?.DKG_AU_UID).toMatch(/^\d+$/);
    }
  });

  it('aborts the update if pre-build clean fails (no swap of a potentially dirty slot)', async () => {
    readFileImpl = async () => 'aaa111';
    makeFetchOk('bbb222');
    // readdir() must succeed and return entries — otherwise cleanGeneratedOutputs
    // returns early ("nothing to pre-clean") on ENOENT and never reaches rm,
    // which would silently bypass this regression test.
    readdirImpl = async (path: any) => {
      if (String(path).endsWith('/packages')) return DEFAULT_READDIR_PKG_ENTRIES.slice();
      return [];
    };
    // Force the Node-based clean to throw on rm of dist (EACCES-ish), and
    // also force the git clean -fdx fallback to fail. Update must abort.
    _autoUpdateIo.rm = (async () => {
      throw new Error('EACCES: simulated permission denied on dist');
    }) as any;
    execFileImpl = async (file: string, args: string[]) => {
      if (file === 'git' && args[0] === 'clean' && args[1] === '-fdx') {
        throw new Error('EACCES: simulated permission denied on git clean');
      }
      return { stdout: '', stderr: '' };
    };
    const logs: string[] = [];
    const result = await performUpdate(AU, (m) => logs.push(m));
    expect(result).toBe(false);
    expect(logs.some(m => m.includes('pre-build clean failed') && m.includes('Aborting'))).toBe(true);
    // No slot swap should have happened.
    expect(swapSlotCalls.length).toBe(0);
  });

});
