/**
 * engine/runtimes/copilot.js — GitHub Copilot CLI runtime adapter (P-1d4a8e7c).
 *
 * Implements the same contract as engine/runtimes/claude.js (see the header of
 * that file for the contract surface). Built against the empirical findings in
 * docs/copilot-cli-schema.md (P-8f2c4d9b spike) — every flag and parser branch
 * here traces back to a real CLI invocation captured during the spike.
 *
 * Headline behaviors that differ from Claude and surface as capability flags:
 *   - promptViaArg: false           — stdin works; -p with a 40 KB prompt hits
 *                                    Windows ARG_MAX (~32 KB) and CreateProcess
 *                                    rejects the spawn outright. Stdin is the
 *                                    only safe path on Windows.
 *   - systemPromptFile: false       — no --system-prompt-file flag exists, so
 *                                    buildPrompt() prepends a <system> block.
 *   - costTracking: false           — result.usage has premiumRequests count
 *                                    and durations only; no USD or per-token.
 *   - modelShorthands: false        — Copilot itself expects full model IDs like
 *                                    "claude-sonnet-4.5" or "gpt-5.4". The
 *                                    adapter translates Minions' internal family
 *                                    aliases before argv construction.
 *   - modelDiscovery: true          — GET https://api.githubcopilot.com/models
 *                                    with `gh auth token` Bearer returns the
 *                                    catalog (24 models on the test account).
 *   - effortLevels: true (max → xhigh) — Copilot accepts low/medium/high/xhigh;
 *                                    'max' is a Claude-ism that maps to 'xhigh'.
 *   - budgetCap / bareMode / fallbackModel: false — no equivalent flags.
 */

const fs = require('fs');
const https = require('https');
const os = require('os');
const path = require('path');
const { execSync } = require('child_process');
const { FAILURE_CLASS, safeWrite, ts, resolveEngineCacheDir } = require('../shared');

const ENGINE_DIR = __dirname.replace(/[\\/]runtimes$/, '');
const _CACHE_DIR = resolveEngineCacheDir(ENGINE_DIR);
const isWin = process.platform === 'win32';

// ── Binary Resolution ───────────────────────────────────────────────────────
//
// Two install paths are supported:
//   1. Standalone `copilot` (preferred) — WinGet, scoop, or manual install. PATH
//      probe finds it; we cache the resolved path with `leadingArgs: []`.
//   2. `gh copilot` extension fallback — invoked as `gh copilot ...`. We return
//      `leadingArgs: ['copilot']` so engine/spawn-agent.js prepends "copilot"
//      to the gh binary invocation. NOTE: the older gh-copilot extension is
//      the explain/suggest UX, NOT the v1.0.36 agent CLI; flag support varies.
//      We surface it as best-effort and let preflight warn.
//
// We deliberately do NOT npm-probe — Copilot is not an npm package. Doing so
// would be confusing dead code that suggests an install path that doesn't exist.

const CAPS_FILE = path.join(_CACHE_DIR, 'copilot-caps.json');
const MODELS_CACHE = path.join(_CACHE_DIR, 'copilot-models.json');

// Bump when cache shape or resolution semantics change so we can ignore stale
// entries from earlier resolver versions. v1 (pre-issue-2370): picked
// `where copilot` line 1, which silently selected the POSIX bash shim on
// Windows npm installs and chained into a node.exe-loads-bash-script crash.
// v2: parses the full `where` output, prefers .exe, and resolves npm shims
// to the sibling node_modules/@github/copilot/index.js JS entry.
const CAPS_SCHEMA_VERSION = 2;

function _safeJson(p) {
  try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return null; }
}

function _safeWriteJson(p, obj) {
  try { fs.writeFileSync(p, JSON.stringify(obj, null, 2)); } catch { /* best effort */ }
}

function _execSyncCapture(cmd, env, timeoutMs = 10000) {
  return execSync(cmd, { encoding: 'utf8', env, timeout: timeoutMs, windowsHide: true });
}

/**
 * Pure helper: choose the best `copilot` binary from a `where` / `which`
 * output buffer. Returns `{ bin, native }` or null.
 *
 * Background (issue #2370): on Windows `where copilot` enumerates every file
 * along PATH in PATHEXT order, e.g. for an `npm i -g @github/copilot` install:
 *   C:\Users\<u>\AppData\Roaming\npm\copilot         (POSIX `#!/bin/sh` shim)
 *   C:\Users\<u>\AppData\Roaming\npm\copilot.cmd     (Windows cmd shim)
 *   C:\Users\<u>\AppData\Roaming\npm\copilot.ps1     (PowerShell shim)
 * The legacy resolver took `.split('\n')[0]` (the extensionless shim) and
 * marked it `native: false`, which made engine/llm.js spawn `node.exe <shim>`.
 * Node parsed the bash script as JS and exit-1'd in <1s, silently breaking
 * every Copilot dispatch.
 *
 * Resolution priority on Windows:
 *   1. `.exe`                                  → native:true, run directly
 *   2. JS entry sibling of a `.cmd`/`.bat`/shim (npm install pattern) → native:false
 *   3. `.js`                                   → native:false, loaded by Node
 * Anything else (`.cmd`/`.bat` without a JS sibling, raw POSIX shim) is
 * skipped — spawning it via the existing llm.js code path would fail.
 *
 * On non-Windows the standard `which` always returns one usable file, so we
 * pick the first that exists. `existsSync` is parameterized so unit tests
 * can simulate sibling-file presence without touching the real filesystem.
 */
function _pickStandaloneCopilotFromOutput(rawOutput, { existsSync = fs.existsSync, isWin: _isWin = isWin } = {}) {
  if (rawOutput == null) return null;
  const lines = String(rawOutput).split(/\r?\n/).map(s => s.trim()).filter(Boolean);
  if (lines.length === 0) return null;
  const existing = lines.filter(l => {
    try { return existsSync(l); } catch { return false; }
  });
  if (existing.length === 0) return null;

  if (!_isWin) {
    // POSIX `which` returns one line; first existing entry wins. Mark native
    // when there's no extension or an executable extension — but since
    // engine/llm.js only checks `.exe` for native on Windows and otherwise
    // routes through process.execPath on non-native, we keep native:true on
    // POSIX (matches legacy behavior — spawn(<bin>) works directly).
    return { bin: existing[0], native: true };
  }

  const byExt = (ext) => existing.find(l => l.toLowerCase().endsWith(ext));
  const exe = byExt('.exe');
  if (exe) return { bin: exe, native: true };

  // npm shim path: copilot.cmd / copilot.bat / extensionless shim → look for
  // a Node-loadable JS entry next to it. npm places the package at
  // <shim_dir>\node_modules\@github\copilot\index.js for `@github/copilot`.
  // The .cmd shim itself just invokes `node "%~dp0\node_modules\@github\copilot\index.js"`,
  // so resolving directly to the JS entry sidesteps both the .cmd-via-node
  // crash AND the POSIX-shim-via-node crash.
  const shim = byExt('.cmd') || byExt('.bat') || existing.find(l => !path.extname(l));
  if (shim) {
    const jsEntry = _resolveNpmCopilotJsEntry(shim, existsSync);
    if (jsEntry) return { bin: jsEntry, native: false };
  }

  // Fall back to a bare `.js` listing (rare — npm rarely emits a `.js`
  // alongside the shims, but a hand-rolled install may).
  const js = byExt('.js');
  if (js) return { bin: js, native: false };

  // Nothing safely spawnable. Let resolveBinary fall through to the gh
  // extension probe rather than caching a known-broken path.
  return null;
}

/**
 * Given a Windows shim path like `C:\...\npm\copilot.cmd` (or the
 * extensionless POSIX shim sibling), look for the actual JS entry the shim
 * dispatches to. Returns the absolute path or null when none exists.
 *
 * Layout produced by `npm i -g @github/copilot` on Windows:
 *   <prefix>\copilot              (POSIX shim)
 *   <prefix>\copilot.cmd          (Windows shim)
 *   <prefix>\copilot.ps1
 *   <prefix>\node_modules\@github\copilot\index.js   ← what the shim invokes
 *
 * We probe a small list of conventional JS entry locations rather than
 * parsing the shim itself — shim contents are npm-version-specific and not
 * a stable contract.
 */
function _resolveNpmCopilotJsEntry(shimPath, existsSync = fs.existsSync) {
  if (!shimPath) return null;
  // Separator-agnostic dirname. path.dirname() on POSIX hosts (e.g. Linux CI
  // runners) does not recognize backslashes, so feeding a Windows shim path
  // like `C:\Users\u\AppData\Roaming\npm\copilot.cmd` returns '.' and breaks
  // every npm-layout probe. Split on whichever separator appears last.
  const lastSep = Math.max(shimPath.lastIndexOf('\\'), shimPath.lastIndexOf('/'));
  const dir = lastSep >= 0 ? shimPath.slice(0, lastSep) : '.';
  const candidates = [
    path.join(dir, 'node_modules', '@github', 'copilot', 'index.js'),
    path.join(dir, 'node_modules', '@github', 'copilot', 'dist', 'index.js'),
    path.join(dir, 'node_modules', '@github', 'copilot', 'bin', 'index.js'),
    path.join(dir, 'node_modules', '@github', 'copilot-cli', 'index.js'),
  ];
  for (const c of candidates) {
    try { if (existsSync(c)) return c; } catch { /* best effort */ }
  }
  return null;
}

/**
 * Probe PATH for a standalone `copilot` binary. Returns `{ bin, native }` or
 * null. Resilient to non-zero exits (where/which return 1 when nothing found).
 *
 * Native flag:
 *   - true  → spawn directly (`.exe`, POSIX `which` hit)
 *   - false → spawn via process.execPath (Node), bin must be JS-loadable
 */
function _findStandaloneCopilot(env) {
  try {
    const cmd = isWin ? 'where copilot 2>NUL' : 'which copilot 2>/dev/null';
    const raw = _execSyncCapture(cmd, env);
    return _pickStandaloneCopilotFromOutput(raw);
  } catch {
    return null;
  }
}

/**
 * Validate a cached resolveBinary entry against the current schema. Returns
 * true when the cache is safe to reuse, false when it must be re-resolved.
 *
 * Hard invalidation rules:
 *   - missing/non-matching schema version (pre-#2370 caches)
 *   - on Windows, copilotIsNative=false but bin lacks a Node-loadable
 *     extension (extensionless POSIX shim, .cmd, .bat — any of which would
 *     re-trigger the issue #2370 crash on next spawn)
 */
function _isCachedBinUsable(cached) {
  if (!cached || typeof cached !== 'object') return false;
  if (typeof cached.copilotBin !== 'string' || !cached.copilotBin) return false;
  if (cached.version !== CAPS_SCHEMA_VERSION) return false;
  try { if (!fs.existsSync(cached.copilotBin)) return false; } catch { return false; }
  if (isWin && cached.copilotIsNative === false) {
    const ext = path.extname(cached.copilotBin).toLowerCase();
    // Only Node-loadable extensions are safe with native:false on Windows.
    // Anything else (extensionless shim, .cmd, .bat) would crash on spawn.
    if (ext !== '.js' && ext !== '.mjs' && ext !== '.cjs') return false;
  }
  return true;
}

/**
 * Probe `gh` for Copilot support. Returns the absolute path of the `gh`
 * binary when `gh copilot` is invokable, null otherwise.
 *
 * Two install paths produce a working `gh copilot` subcommand and we accept
 * both:
 *   1. gh ≥ ~2.90 ships Copilot as a built-in preview command. `gh extension
 *      list` does NOT list it (it's not an extension) — and attempting to
 *      install the legacy `github/gh-copilot` extension is rejected because
 *      gh already provides the command. Detecting via the extension list
 *      would falsely report "copilot not installed" on every recent gh.
 *   2. gh < ~2.90 with the legacy `gh-copilot` extension installed. The
 *      extension also responds to `gh copilot --help` with exit 0.
 *
 * `gh copilot --help` exits 0 in both cases and non-zero ("unknown command")
 * when neither path is available, so it's a clean unified signal. The probe
 * runs with --no-update-notifier (silences gh's update banner so a slow
 * network doesn't drag the probe over the timeout). If `gh` isn't on PATH
 * the outer try-catch swallows the ENOENT.
 */
function _findGhCopilotExtension(env, { execSyncCapture = _execSyncCapture } = {}) {
  let ghPath = null;
  try {
    const cmd = isWin ? 'where gh 2>NUL' : 'which gh 2>/dev/null';
    const which = execSyncCapture(cmd, env).trim().split('\n')[0].trim();
    if (!which) return null;
    ghPath = which;
  } catch { return null; }
  try {
    // execSync throws on non-zero exit; reaching the return means `gh copilot`
    // is a valid subcommand on this gh version (builtin or legacy extension).
    execSyncCapture('gh copilot --help', env, 5000);
    return ghPath;
  } catch { /* gh present but no copilot subcommand */ }
  return null;
}

/**
 * Resolve the Copilot CLI binary. Returns { bin, native, leadingArgs } or null.
 *
 * Cache shape (engine/copilot-caps.json):
 *   { copilotBin, copilotIsNative, leadingArgs, source, resolvedAt }
 *
 * `source` is 'standalone' or 'gh-extension' — it lets future preflight rules
 * surface "you're on the older gh-copilot extension; consider installing the
 * standalone CLI" warnings without re-probing.
 */
function resolveBinary({ env = process.env } = {}) {
  // 1. Cache hit — fastest path. Validate before trusting: stale pre-#2370
  // entries that point at a POSIX bash shim with native:false would re-crash
  // every spawn until manually wiped, so we re-resolve when the schema or
  // shape doesn't match what _findStandaloneCopilot now produces.
  const cached = _safeJson(CAPS_FILE);
  if (_isCachedBinUsable(cached)) {
    const leadingArgs = Array.isArray(cached.leadingArgs) ? cached.leadingArgs : [];
    return { bin: cached.copilotBin, native: !!cached.copilotIsNative, leadingArgs };
  }

  // 2. Standalone `copilot` first (preferred). Now returns { bin, native }
  // so the resolver no longer has to guess at native-ness from extension alone
  // — _pickStandaloneCopilotFromOutput owns that decision.
  const standalone = _findStandaloneCopilot(env);
  if (standalone) {
    _safeWriteJson(CAPS_FILE, {
      version: CAPS_SCHEMA_VERSION,
      copilotBin: standalone.bin,
      copilotIsNative: standalone.native,
      leadingArgs: [],
      source: 'standalone',
      resolvedAt: new Date().toISOString(),
    });
    return { bin: standalone.bin, native: standalone.native, leadingArgs: [] };
  }

  // 3. `gh copilot` extension fallback (best-effort). gh is itself a native
  // binary (gh.exe / `gh` POSIX), so the .exe / POSIX-passthrough heuristic
  // is fine here — no shim layer to worry about.
  const gh = _findGhCopilotExtension(env);
  if (gh) {
    const native = !isWin || path.extname(gh).toLowerCase() === '.exe';
    _safeWriteJson(CAPS_FILE, {
      version: CAPS_SCHEMA_VERSION,
      copilotBin: gh,
      copilotIsNative: native,
      leadingArgs: ['copilot'],
      source: 'gh-extension',
      resolvedAt: new Date().toISOString(),
    });
    return { bin: gh, native, leadingArgs: ['copilot'] };
  }

  return null;
}

// ── Model Resolution ────────────────────────────────────────────────────────
//
// Copilot models are full IDs (`claude-sonnet-4.5`, `gpt-5.4`, ...). Minions
// still uses the family aliases `haiku` / `sonnet` / `opus` internally, so this
// adapter maps those aliases to Copilot model IDs while passing all other input
// through verbatim. The capability remains false: Copilot CLI does not accept
// these aliases directly.
//
// `resolveModel` ALSO normalizes Anthropic-style hyphenated IDs that callers may
// have inherited from a Claude-runtime config. `claude-opus-4-7` and
// `claude-opus-4-7-1m` are passed through verbatim by Claude (which has no
// catalog), so when a user runs `minions config set-cli copilot` without
// updating `defaultModel` the hyphenated ID lands in Copilot's --model arg and
// the CLI rejects it. We translate the inter-version hyphen to a dot and fall
// through to catalog matching when one is cached, which is the common case for
// installs that have run model discovery at least once.

const _MINIONS_MODEL_ALIASES = {
  haiku: 'claude-haiku-4.5',
  sonnet: 'claude-sonnet-4.5',
  opus: 'claude-opus-4.5',
};

// Read the cached catalog (engine/copilot-models.json) and return a Set of IDs.
// Returns null when the cache is missing/empty so callers can fall through to
// passthrough behavior — never throws, never reaches the network.
function _readCatalogIds() {
  const cached = _safeJson(MODELS_CACHE);
  const list = cached && Array.isArray(cached.models) ? cached.models : null;
  if (!list || list.length === 0) return null;
  const ids = new Set();
  for (const m of list) {
    const id = m && (m.id || m.name);
    if (id) ids.add(String(id));
  }
  return ids.size > 0 ? ids : null;
}

// Translate Anthropic-style hyphenated `claude-(family)-X-Y[-suffix]` to
// Copilot's dot form `claude-(family)-X.Y[-suffix]`. Returns the transformed
// string (or the original when the pattern doesn't match) — a separate catalog
// match check then decides whether the result is usable.
function _hyphenToDotVersion(s) {
  return s.replace(/^(claude-(?:opus|sonnet|haiku))-(\d+)-(\d+)(.*)$/i, '$1-$2.$3$4');
}

function resolveModel(input) {
  if (input == null || input === '') return undefined;
  const s = String(input);
  const mapped = _MINIONS_MODEL_ALIASES[s.toLowerCase()];
  if (mapped) return mapped;

  // Call through module.exports so tests can monkey-patch _readCatalogIds
  // without writing to the live engine/copilot-models.json cache. Same
  // runtime behavior as a direct call when the export hasn't been replaced.
  const catalog = module.exports._readCatalogIds();
  if (catalog && catalog.has(s)) return s;

  const dotted = _hyphenToDotVersion(s);
  if (dotted !== s && catalog) {
    if (catalog.has(dotted)) return dotted;
    // `-1m` Anthropic suffix maps to Copilot's `-1m-internal` variant when
    // present in the catalog. Other suffixes pass through unchanged.
    if (/-1m$/i.test(dotted) && catalog.has(`${dotted}-internal`)) {
      return `${dotted}-internal`;
    }
  }
  // No catalog (test env, no token) OR no match — passthrough so the original
  // input still reaches the CLI for an authoritative reject. Hyphen→dot is
  // applied even without a catalog, since the dot form is universally what
  // Copilot expects and the alternative is a guaranteed reject.
  return dotted !== s && !catalog ? dotted : s;
}

// Heuristic: does `model` look like a Copilot-namespace identifier? Mirrors
// engine/runtimes/claude.js#modelLooksFamiliar so engine/cli.js#_modelLooksIncompatible
// can warn when a fleet/CC switch strands an incompatible model. Accepts the
// three Minions short aliases (`sonnet`/`opus`/`haiku`) since the adapter
// translates them to valid Copilot IDs at argv-build time. Returning false
// means "this looks wrong for Copilot" — anything outside Anthropic / OpenAI /
// catalog membership is flagged.
function modelLooksFamiliar(model) {
  if (!model) return true;
  const m = String(model).toLowerCase();
  if (m === 'sonnet' || m === 'opus' || m === 'haiku') return true;
  if (m.startsWith('claude-')) return true;
  if (m.startsWith('gpt-')) return true;
  if (m.startsWith('o3') || m.startsWith('o4')) return true;
  // Mirror resolveModel: route through module.exports so test monkey-patching
  // of _readCatalogIds reaches this call site too.
  const catalog = module.exports._readCatalogIds();
  if (catalog && catalog.has(m)) return true;
  return false;
}

/**
 * Map effort levels. Copilot accepts low|medium|high|xhigh. The Claude-ism
 * 'max' (used loosely as "give it the most thinking budget") maps to 'xhigh'
 * so a single fleet-wide effort knob works for both runtimes.
 */
function _mapEffort(level) {
  if (level == null || level === '') return undefined;
  const s = String(level);
  if (s === 'max') return 'xhigh';
  return s;
}

// ── Argument Construction ───────────────────────────────────────────────────
//
// Always-on baseline (per docs/copilot-cli-schema.md §3 and the PRD spec):
//   --output-format json -s --no-color --plain-diff --autopilot
//   --allow-all --no-ask-user --log-level error
//
// Conditional flags only emitted when their corresponding opt is set/truthy.
// Copilot has no --verbose flag — never emit it. The `bare` / `maxBudget` /
// `fallbackModel` opts are silently ignored (their capability flags are false
// so engine code shouldn't pass them, but we tolerate them gracefully).

function buildArgs(opts = {}) {
  const {
    model,
    maxTurns,
    effort,
    sessionId,
    addDirs,
    stream,
    disableBuiltinMcps,
    suppressAgentsMd,
    reasoningSummaries,
  } = opts;

  const args = [
    '--output-format', 'json',
    '-s',
    '--no-color',
    '--plain-diff',
    '--autopilot',
    '--allow-all',
    '--no-ask-user',
    '--log-level', 'error',
  ];

  if (Array.isArray(addDirs)) {
    for (const d of addDirs) {
      if (d) args.push('--add-dir', String(d));
    }
  }

  if (maxTurns != null && maxTurns !== '') {
    args.push('--max-autopilot-continues', String(maxTurns));
  }

  if (model) args.push('--model', String(model));

  const mappedEffort = _mapEffort(effort);
  if (mappedEffort) args.push('--effort', mappedEffort);

  // Toggle flags — strict-true gating to avoid surprise opt-in from truthy
  // strings or 1/0 numbers in config.
  if (disableBuiltinMcps === true) args.push('--disable-builtin-mcps');
  if (suppressAgentsMd === true) args.push('--no-custom-instructions');
  if (reasoningSummaries === true) args.push('--enable-reasoning-summaries');

  // --stream takes a value: 'on' or 'off'. Caller passes that exact value.
  if (stream === 'on' || stream === 'off') {
    args.push('--stream', stream);
  }

  // --resume uses the equals-form per Copilot help: --resume[=value]. Without
  // the `=`, commander.js treats the next token as a positional, not the value.
  if (sessionId) args.push(`--resume=${sessionId}`);

  return args;
}

function buildSpawnFlags(opts = {}) {
  const flags = ['--runtime', 'copilot'];
  if (opts.maxTurns != null) flags.push('--max-turns', String(opts.maxTurns));
  if (opts.model) flags.push('--model', String(opts.model));
  if (opts.allowedTools) flags.push('--allowedTools', String(opts.allowedTools));
  if (module.exports.capabilities.effortLevels && opts.effort) flags.push('--effort', String(opts.effort));
  if (module.exports.capabilities.sessionResume && opts.sessionId) flags.push('--resume', String(opts.sessionId));
  if (module.exports.capabilities.budgetCap && opts.maxBudget != null) flags.push('--max-budget-usd', String(opts.maxBudget));
  if (module.exports.capabilities.bareMode && opts.bare === true) flags.push('--bare');
  if (module.exports.capabilities.fallbackModel && opts.fallbackModel) flags.push('--fallback-model', String(opts.fallbackModel));
  if (opts.stream != null && opts.stream !== '') flags.push('--stream', String(opts.stream));
  if (opts.disableBuiltinMcps === true) flags.push('--disable-builtin-mcps');
  if (opts.suppressAgentsMd === true) flags.push('--no-custom-instructions');
  if (opts.reasoningSummaries === true) flags.push('--enable-reasoning-summaries');
  return flags;
}

function getUserAssetDirs({ homeDir = os.homedir() } = {}) {
  return [
    path.join(homeDir, '.copilot'),
    path.join(homeDir, '.agents'),
  ];
}

function getSkillRoots({ homeDir = os.homedir(), project = null } = {}) {
  const roots = [
    { scope: 'copilot', dir: path.join(homeDir, '.copilot', 'skills') },
    { scope: 'agent-skill', dir: path.join(homeDir, '.agents', 'skills') },
  ];
  if (project?.localPath) {
    roots.push(
      {
        scope: 'project',
        projectName: project.name,
        dir: path.join(project.localPath, '.github', 'skills'),
      },
      {
        scope: 'project',
        projectName: project.name,
        dir: path.join(project.localPath, '.agents', 'skills'),
      },
    );
  }
  return roots;
}

function getSkillWriteTargets({ homeDir = os.homedir(), project = null } = {}) {
  return {
    personal: path.join(homeDir, '.copilot', 'skills'),
    project: project?.localPath ? path.join(project.localPath, '.github', 'skills') : null,
  };
}

// Stamped into every session.json this adapter writes so the pre-spawn resume
// path can detect "session was produced by a different runtime" — Copilot
// rejects Claude session IDs (and vice versa) with "No conversation found",
// which would otherwise burn a retry slot before the post-failure cleanup at
// engine.js:1195 fires. See W-mot9fwya000d09cb.
const RUNTIME_NAME = 'copilot';

function getResumeSessionId({ agentId, branchName, agentsDir, maxAgeMs = 2 * 60 * 60 * 1000, logger = console } = {}) {
  if (!agentId || agentId.startsWith('temp-') || !agentsDir) return null;
  const sessionPath = path.join(agentsDir, agentId, 'session.json');
  try {
    const sessionFile = _safeJson(sessionPath);
    if (!sessionFile?.sessionId || !sessionFile.savedAt) return null;

    // Runtime-mismatch invalidation. Distinct from stale-by-age: the session is
    // structurally unusable on this runtime, so drop it AND clear session.json
    // so the next dispatch starts fresh instead of failing with --resume.
    // Legacy sessions (no `runtime` field) are treated as compatible — opt-in
    // check, no false invalidations on first deploy.
    if (sessionFile.runtime && sessionFile.runtime !== RUNTIME_NAME) {
      if (logger && typeof logger.info === 'function') {
        logger.info(`Skipping resume for ${agentId}: runtime mismatch (session: ${sessionFile.runtime}, current: ${RUNTIME_NAME}) — clearing session.json`);
      }
      try { fs.unlinkSync(sessionPath); } catch {}
      return null;
    }

    const sessionAge = Date.now() - new Date(sessionFile.savedAt).getTime();
    const sameBranch = branchName && sessionFile.branch && sessionFile.branch === branchName;
    if (sessionAge < maxAgeMs && sameBranch) {
      if (logger && typeof logger.info === 'function') {
        logger.info(`Resuming session ${sessionFile.sessionId} for ${agentId} on branch ${branchName} (age: ${Math.round(sessionAge / 60000)}min)`);
      }
      return sessionFile.sessionId;
    }
  } catch (e) {
    if (logger && typeof logger.warn === 'function') logger.warn('session resume lookup: ' + e.message);
  }
  return null;
}

function saveSession({ agentId, dispatchId, branch, sessionId, agentsDir, now = ts, writeJson = safeWrite, logger = console } = {}) {
  if (!sessionId || !agentId || agentId.startsWith('temp-') || !agentsDir) return false;
  try {
    writeJson(path.join(agentsDir, agentId, 'session.json'), {
      sessionId,
      dispatchId,
      savedAt: typeof now === 'function' ? now() : new Date().toISOString(),
      branch: branch || null,
      runtime: RUNTIME_NAME,
    });
    return true;
  } catch (err) {
    if (logger && typeof logger.warn === 'function') logger.warn(`Session save: ${err.message}`);
    return false;
  }
}

function detectPermissionGate() {
  return false;
}

function getPromptDeliveryMode() {
  return 'stdin';
}

function usesSystemPromptFile() {
  return false;
}

function _runtimeFailureClass(code) {
  if (code === 'auth-failure' || code === 'budget-exceeded') return FAILURE_CLASS.PERMISSION_BLOCKED;
  if (code === 'unknown-model') return FAILURE_CLASS.CONFIG_ERROR;
  if (code === 'model-unavailable') return FAILURE_CLASS.MODEL_UNAVAILABLE;
  if (code === 'rate-limit') return FAILURE_CLASS.NETWORK_ERROR;
  if (code === 'crash') return FAILURE_CLASS.SPAWN_ERROR;
  return null;
}

function classifyFailure({ code, stdout = '', stderr = '', fallback } = {}) {
  if (code === 78) return { failureClass: FAILURE_CLASS.CONFIG_ERROR, retryable: false, message: 'Copilot configuration error' };
  const parsed = parseError(`${stdout || ''}\n${stderr || ''}`);
  const runtimeClass = parsed.code ? _runtimeFailureClass(parsed.code) : null;
  if (runtimeClass) return { failureClass: runtimeClass, retryable: parsed.retriable !== false, message: parsed.message || '' };
  const fallbackClass = typeof fallback === 'function' ? fallback(code, stdout, stderr) : FAILURE_CLASS.UNKNOWN;
  return { failureClass: fallbackClass, retryable: parsed.retriable !== false, message: parsed.message || '' };
}

// ── Prompt Construction ─────────────────────────────────────────────────────
//
// Copilot has no --system-prompt-file flag, so we deliver the system prompt
// as a <system>...</system> block prepended to the user prompt. Mirrors the
// convention from Anthropic tool-use docs and is recognized as "system role"
// content by every model in the Copilot catalog.

function buildPrompt(promptText, sysPromptText, opts = {}) {
  const user = promptText == null ? '' : String(promptText);
  if (opts && opts.sessionId) return user;
  if (sysPromptText == null || sysPromptText === '') return user;
  return `<system>\n${String(sysPromptText)}\n</system>\n\n${user}`;
}

// ── Output Parsing ──────────────────────────────────────────────────────────
//
// Whitelist of event types observed during the spike (docs/copilot-cli-schema.md
// §5.1). Any other type is wrapped as `{ type: 'ignore', original: <type> }` so
// downstream consumers can drop them without crashing.

const KNOWN_EVENT_TYPES = new Set([
  'session.mcp_server_status_changed',
  'session.mcp_servers_loaded',
  'session.skills_loaded',
  'session.tools_updated',
  'session.info',
  'session.task_complete',
  'user.message',
  'assistant.turn_start',
  'assistant.turn_end',
  'assistant.reasoning',
  'assistant.reasoning_delta',
  'assistant.message_delta',
  'assistant.message',
  'tool.execution_start',
  'tool.execution_complete',
  'result',
  // Edge case observed once during stdin testing — appears to be a meta event
  // for tool invocation. Allowlisted so it doesn't get marked 'ignore'.
  'function',
]);

/**
 * Parse the full JSONL output of a Copilot CLI invocation.
 * Returns { text, usage, sessionId, model } — same shape as the Claude adapter
 * so engine/lifecycle.js can consume both transparently.
 *
 *   - text:      concatenation of answer `assistant.message.data.content`
 *                values, plus trailing deltas if the CLI exits before a final
 *                `assistant.message`. Narration attached to tool requests is
 *                progress text and is excluded from the final answer.
 *   - usage:     mapped from the terminal `result` event. Copilot doesn't
 *                report cost/tokens — those fields are NULL, not 0, so the
 *                dashboard can distinguish "Copilot didn't tell us" from
 *                "this turn cost $0".
 *   - sessionId: from `result.sessionId` (camelCase — Copilot's spelling)
 *   - model:     from the first `session.tools_updated.data.model` event
 *
 * `maxTextLength` tail-slices the concatenated text — VERDICTs / completion
 * blocks live at the END of agent output, so we slice from the tail.
 */
function parseOutput(raw, { maxTextLength = 0 } = {}) {
  const safeRaw = raw == null ? '' : String(raw);
  if (!safeRaw) return { text: '', usage: null, sessionId: null, model: null };

  const messageContents = [];
  let usage = null;
  let sessionId = null;
  let model = null;
  let outputTokensTotal = 0;
  let turnEndCount = 0;
  let pendingDeltaContent = '';
  let taskCompleteSummary = '';

  for (const rawLine of safeRaw.split('\n')) {
    const line = rawLine.trim();
    if (!line || !line.startsWith('{')) continue;
    let obj;
    try { obj = JSON.parse(line); } catch { continue; }
    if (!obj || typeof obj !== 'object') continue;

    const type = obj.type;
    if (type === 'assistant.message_delta') {
      const delta = obj.data?.deltaContent;
      if (typeof delta === 'string') pendingDeltaContent += delta;
    } else if (type === 'assistant.message') {
      const content = obj.data?.content;
      const toolRequests = obj.data?.toolRequests;
      const hasToolRequests = Array.isArray(toolRequests) && toolRequests.length > 0;
      if (hasToolRequests) {
        for (const tr of toolRequests) {
          const summary = tr?.name === 'task_complete' ? tr.arguments?.summary || tr.intentionSummary : '';
          if (typeof summary === 'string' && summary) taskCompleteSummary = summary;
        }
      }
      if (typeof content === 'string') {
        if (content && !hasToolRequests) messageContents.push(content);
        pendingDeltaContent = '';
      }
      const ot = obj.data?.outputTokens;
      if (typeof ot === 'number') outputTokensTotal += ot;
    } else if (type === 'tool.execution_start') {
      const summary = obj.data?.toolName === 'task_complete' ? obj.data?.arguments?.summary : '';
      if (typeof summary === 'string' && summary) taskCompleteSummary = summary;
    } else if (type === 'session.task_complete') {
      const summary = obj.data?.summary;
      if (typeof summary === 'string' && summary) taskCompleteSummary = summary;
    } else if (type === 'assistant.turn_end') {
      turnEndCount += 1;
    } else if (type === 'session.tools_updated' && model == null) {
      const m = obj.data?.model;
      if (typeof m === 'string' && m) model = m;
    } else if (type === 'result') {
      if (typeof obj.sessionId === 'string') sessionId = obj.sessionId;
      const u = obj.usage || {};
      usage = {
        // Cost / token fields are NULL — Copilot doesn't expose them.
        // Mapping them to 0 would falsely suggest "this turn cost $0" in the
        // dashboard cost telemetry.
        costUsd: null,
        inputTokens: null,
        // outputTokens is recovered from per-turn assistant.message events
        // since the result event itself doesn't report it.
        outputTokens: outputTokensTotal > 0 ? outputTokensTotal : null,
        cacheRead: null,
        cacheCreation: null,
        durationMs: typeof u.totalApiDurationMs === 'number' ? u.totalApiDurationMs : 0,
        numTurns: turnEndCount,
        // Copilot-specific extension — preserved alongside the standard shape
        // so the engine can distinguish "this turn cost N premium requests"
        // from token accounting on the Claude path.
        premiumRequests: typeof u.premiumRequests === 'number' ? u.premiumRequests : 0,
        sessionDurationMs: typeof u.sessionDurationMs === 'number' ? u.sessionDurationMs : 0,
      };
    }
  }

  let text = messageContents.join('') + pendingDeltaContent;
  if (!text && taskCompleteSummary) text = taskCompleteSummary;
  if (maxTextLength && text.length > maxTextLength) {
    text = text.slice(-maxTextLength);
  }

  return { text, usage, sessionId, model };
}

/**
 * Parse a single line from the Copilot JSONL stream. Returns the parsed event
 * object, or null when the line is empty / non-JSON.
 *
 * Unknown event types are NOT dropped — they're rewrapped with
 * `{ type: 'ignore', original }` so consumers can log/track schema drift
 * without crashing on a new event the spike didn't observe.
 */
function parseStreamChunk(line) {
  const trimmed = line == null ? '' : String(line).trim();
  if (!trimmed || !trimmed.startsWith('{')) return null;
  let obj;
  try { obj = JSON.parse(trimmed); } catch { return null; }
  if (!obj || typeof obj !== 'object' || typeof obj.type !== 'string') return obj || null;
  if (!KNOWN_EVENT_TYPES.has(obj.type)) {
    return { type: 'ignore', original: obj.type, raw: obj };
  }
  return obj;
}

// ── Error Normalization ─────────────────────────────────────────────────────

// In-memory model-discovery cache used by parseError's invalid-model branch
// (W-mpmwxni2000c25c7-a). parseError is synchronous — to enrich the error
// message with the live model catalog without adding a per-error HTTP round
// trip, the engine pre-warms this cache via `_warmModelCache()` (e.g. during
// preflight or the first listModels() call from the dashboard). Concurrent
// callers share the in-flight promise so the API is only hit once even when
// multiple agents fail simultaneously. `_modelDiscoveryResults` stores the
// resolved catalog (or `[]` on failure); parseError reads it synchronously.
let _modelDiscoveryResults = null;
let _modelDiscoveryPromise = null;

async function _warmModelCache(opts = {}) {
  if (_modelDiscoveryPromise) return _modelDiscoveryPromise;
  if (Array.isArray(_modelDiscoveryResults)) return _modelDiscoveryResults;
  _modelDiscoveryPromise = listModels(opts).then(
    (models) => { _modelDiscoveryResults = Array.isArray(models) ? models : []; return _modelDiscoveryResults; },
    () => { _modelDiscoveryResults = []; return _modelDiscoveryResults; },
  );
  try {
    return await _modelDiscoveryPromise;
  } finally {
    _modelDiscoveryPromise = null;
  }
}

function _resetModelCache({ models = null } = {}) {
  _modelDiscoveryResults = Array.isArray(models) ? models : (models === null ? null : []);
  _modelDiscoveryPromise = null;
}

// Pull a model id out of a Copilot/Anthropic-style invalid-model error string.
// Handles the shapes observed in the wild:
//   "Unknown model: banana"
//   "model not found: gpt-5.4"
//   "model 'gpt-x' is invalid"
//   "Invalid model id: claude-sonnet"
//   {"error":"model_not_found","model":"gpt-x"} / {"model":"gpt-x"}
function _extractInvalidModelName(text) {
  if (!text) return null;
  const patterns = [
    /(?:unknown|invalid)\s+model(?:\s+id)?\s*[:=]?\s*['"`]?([A-Za-z0-9._\/-]+)['"`]?/i,
    /model\s+['"`]([^'"`]+)['"`]\s+(?:not\s+found|is\s+invalid|is\s+unknown|invalid)/i,
    /model\s+(?:not\s+found|invalid|unknown)\s*[:=]?\s*['"`]?([A-Za-z0-9._\/-]+)['"`]?/i,
    /"model"\s*:\s*"([^"]+)"/i,
  ];
  for (const re of patterns) {
    const m = text.match(re);
    if (m && m[1]) return m[1];
  }
  return null;
}

function _collectErrorSignal(rawOutput) {
  const text = rawOutput == null ? '' : String(rawOutput);
  if (!text) return '';

  const signals = [];
  let sawJsonLine = false;
  for (const rawLine of text.split('\n')) {
    const line = rawLine.trim();
    if (!line) continue;
    if (!line.startsWith('{')) {
      signals.push(line);
      continue;
    }

    let obj;
    try { obj = JSON.parse(line); } catch { continue; }
    if (!obj || typeof obj !== 'object') continue;
    sawJsonLine = true;

    const type = String(obj.type || '');
    const subtype = String(obj.subtype || '');
    const eventType = String(obj.event?.type || '');
    const isErrorEvent = type === 'error'
      || eventType === 'error'
      || subtype.startsWith('error')
      || obj.is_error === true
      || obj.error != null
      || obj.data?.error != null;
    if (!isErrorEvent) continue;

    for (const value of [
      obj.error,
      obj.message,
      obj.stderr,
      obj.result,
      obj.data?.error,
      obj.data?.message,
      obj.data?.stderr,
      obj.event?.error,
      obj.event?.message,
      subtype,
    ]) {
      if (typeof value === 'string' && value.trim()) signals.push(value.trim());
    }
  }

  if (signals.length > 0) return signals.join('\n');
  return sawJsonLine ? '' : text;
}

function _readModelIdsFromDiskCache() {
  try {
    const text = fs.readFileSync(MODELS_CACHE, 'utf8');
    const obj = JSON.parse(text);
    if (!obj || !Array.isArray(obj.models)) return null;
    const ids = obj.models.map(m => m && m.id ? String(m.id) : null).filter(Boolean);
    return ids.length > 0 ? ids : null;
  } catch { return null; }
}

function _resolveCachedModelIds() {
  // In-memory cache (seeded by `_warmModelCache()` or test `_resetModelCache`)
  // wins over disk so unit tests stay hermetic. Production code that hasn't
  // warmed the in-memory cache still gets the catalog via the disk file that
  // `engine/model-discovery.js#getRuntimeModels` populates during preflight.
  if (Array.isArray(_modelDiscoveryResults) && _modelDiscoveryResults.length > 0) {
    return _modelDiscoveryResults.map(m => m && m.id ? String(m.id) : null).filter(Boolean);
  }
  return _readModelIdsFromDiskCache();
}

function parseError(rawOutput) {
  const text = _collectErrorSignal(rawOutput);
  if (!text) return { message: '', code: null, retriable: true };
  const lower = text.toLowerCase();

  const hasExplicitAuthFailure = /not authenticated|copilot login|please.*log.*in|\bunauthorized\b/i.test(text);
  const hasAuthStatusCode = /\b(?:http(?:\/\d(?:\.\d)?)?|status(?:\s+code)?|statuscode|response(?:\s+status)?|api(?:\s+(?:error|response|status))?)\s*[:=]?\s*(?:401|403)\b|\b(?:401\s+unauthorized|403\s+forbidden)\b/i.test(text);
  if (hasExplicitAuthFailure || hasAuthStatusCode) {
    return { message: text, code: 'auth-failure', retriable: false };
  }
  // W-mpmwxni2000c25c7-a — Configured model isn't in the Copilot catalog
  // (typo, deprecated id, 400-style invalid-model response). Classify as
  // MODEL_UNAVAILABLE with retriable: false so the engine surfaces an
  // actionable error instead of looping the dispatch. Must match BEFORE the
  // overload branch — the overload regex catches `model.*unavailable`,
  // which would otherwise swallow legitimate "model not found" responses.
  if (/unknown\s+model|model\s+not\s+found|invalid\s+model|model_not_found|400[^a-z]+(?:bad\s+request|invalid|model)|model.*\b400\b/i.test(text)) {
    const name = _extractInvalidModelName(text) || 'unknown';
    const ids = _resolveCachedModelIds();
    const tail = (ids && ids.length > 0)
      ? `Available models: ${ids.join(', ')}`
      : 'Configure a valid model in Settings → Engine.';
    return {
      message: `Model "${name}" not available for runtime copilot. ${tail}`,
      code: 'model-unavailable',
      retriable: false,
    };
  }
  // W-mpg6isvy000xca4d — Copilot has no --fallback-model flag; classify
  // overloaded / 503 / service_unavailable as MODEL_UNAVAILABLE so the engine
  // retry can OVERRIDE --model with engine.copilotFallbackModel. Match before
  // rate-limit so 503/overload never gets misread as a 429 (which would
  // bucket into NETWORK_ERROR and re-spawn against the same broken model).
  if (/overloaded_error|service[_ ]unavailable|model.*(?:unavailable|overloaded)|\b503\b|temporarily unavailable/i.test(text)) {
    return { message: text, code: 'model-unavailable', retriable: true };
  }
  if (/rate limit|too many requests|\b429\b/i.test(text)) {
    return { message: text, code: 'rate-limit', retriable: true };
  }
  if (/budget.*exceed|premium.*limit.*reach|quota.*exceed/i.test(lower)) {
    return { message: text, code: 'budget-exceeded', retriable: false };
  }
  if (/internal error|panic|uncaught|copilot.*crashed|fatal: copilot/i.test(lower)) {
    return { message: text, code: 'crash', retriable: true };
  }
  return { message: '', code: null, retriable: true };
}

// ── Model Discovery ─────────────────────────────────────────────────────────
//
// GET https://api.githubcopilot.com/models with a Bearer token.
// Token resolution priority:
//   1. process.env.GH_TOKEN
//   2. process.env.COPILOT_GITHUB_TOKEN
//   3. (best-effort) `gh auth token` — only if env is empty
//
// All failure modes (no token, network error, non-200 status, malformed JSON,
// no chat-type models in response) return null. Returning null tells the
// dashboard to fall back to free-text input.

function _resolveCopilotToken(env) {
  if (env.GH_TOKEN) return env.GH_TOKEN.trim();
  if (env.COPILOT_GITHUB_TOKEN) return env.COPILOT_GITHUB_TOKEN.trim();
  try {
    const out = _execSyncCapture('gh auth token', env, 5000).trim();
    if (out) return out;
  } catch { /* gh not installed or not authed */ }
  return null;
}

function _httpsGetJson(url, headers, timeoutMs = 10000) {
  return new Promise((resolve) => {
    let parsed;
    try { parsed = new URL(url); } catch { return resolve({ status: 0, body: null, error: 'invalid-url' }); }
    const opts = {
      method: 'GET',
      hostname: parsed.hostname,
      port: parsed.port || 443,
      path: parsed.pathname + parsed.search,
      headers,
      timeout: timeoutMs,
    };
    const req = https.request(opts, (res) => {
      let buf = '';
      res.setEncoding('utf8');
      res.on('data', (chunk) => { buf += chunk; });
      res.on('end', () => {
        let body = null;
        try { body = JSON.parse(buf); } catch { /* non-JSON response */ }
        resolve({ status: res.statusCode || 0, body, raw: buf });
      });
    });
    req.on('error', (err) => resolve({ status: 0, body: null, error: err.message }));
    req.on('timeout', () => { try { req.destroy(); } catch {} resolve({ status: 0, body: null, error: 'timeout' }); });
    req.end();
  });
}

/**
 * Fetch the Copilot model catalog. Returns `Promise<{id,name,provider}[] | null>`.
 * `null` means "couldn't reach the API or the response wasn't usable" — the
 * settings UI falls back to free-text input.
 *
 * Filters applied to `data[]`:
 *   - drop embedding-only models (capabilities.type !== 'chat')
 *   - drop disabled models (policy.state must be 'enabled' OR preview must be true)
 */
async function listModels({ env = process.env, timeoutMs = 10000 } = {}) {
  const token = _resolveCopilotToken(env);
  if (!token) return null;
  const result = await _httpsGetJson('https://api.githubcopilot.com/models', {
    'Authorization': `Bearer ${token}`,
    'Accept': 'application/json',
    // The Copilot models API expects an editor identifier; the values mirror
    // what the CLI itself sends so the API treats us like a normal client.
    'Editor-Version': 'vscode/1.95.0',
    'Editor-Plugin-Version': 'copilot/1.0.36',
    'User-Agent': 'GitHubCopilotChat/0.20.0',
  }, timeoutMs);
  if (result.status !== 200 || !result.body || !Array.isArray(result.body.data)) return null;

  const models = [];
  for (const m of result.body.data) {
    if (!m || typeof m !== 'object') continue;
    if (m.capabilities?.type !== 'chat') continue;
    const enabled = m.policy?.state === 'enabled' || m.preview === true;
    if (!enabled) continue;
    models.push({ id: String(m.id), name: m.name ? String(m.name) : String(m.id), provider: m.vendor ? String(m.vendor) : '' });
  }
  if (models.length === 0) return null;
  return models;
}

// ── Stream Consumer ─────────────────────────────────────────────────────────
//
// Per-stream consumer factory invoked by engine/llm.js's accumulator. Owns
// Copilot-specific per-stream state (delta-content buffer, task_complete
// signal). Translates Copilot event shapes into ctx callbacks.
//
// `ctx` shape (provided by accumulator):
//   maxTextLength, pushText(value), pushToolUse(name, input),
//   notifyThinking(), notifyTaskComplete(summary, success),
//   setUsage(usage), setSessionId(id), setText(value),
//   toolUseAlreadySeen(name, input)

function _copilotAssistantMessageHasTools(obj) {
  const requests = obj?.data?.toolRequests;
  return Array.isArray(requests) && requests.length > 0;
}

function createStreamConsumer(ctx) {
  // Copilot streams `assistant.message_delta` with `data.deltaContent` chunks
  // before emitting `assistant.message`. Tool-request narration ("I'll
  // inspect...") is progress text only — terminal text comes from non-tool
  // assistant messages or trailing deltas.
  let copilotMessageBuffer = '';
  let terminalText = '';
  let taskCompleteSummary = '';
  // Tracks whether a non-tool `assistant.message` has already pushed terminal
  // text in this stream. Used to insert a `\n\n` paragraph break between
  // back-to-back terminating messages so consumers don't render the welded
  // form `...prev.next...`. First push is NEVER prefixed (would leave every
  // reply with a leading blank line). Reset by reset() on stream restart.
  let _hadPriorAssistantMessage = false;

  function _captureTaskComplete(summary, success = true, { clearBuffer = false } = {}) {
    if (typeof summary !== 'string' || !summary) return;
    taskCompleteSummary = summary;
    if (clearBuffer) copilotMessageBuffer = '';
    if (!terminalText && !copilotMessageBuffer) {
      ctx.pushText(summary);
    }
    ctx.notifyTaskComplete(summary, success !== false);
  }

  function consume(obj) {
    if (!obj || typeof obj !== 'object') return;

    if (obj.type === 'result' && typeof obj.sessionId === 'string') {
      ctx.setSessionId(obj.sessionId);
      // The result event is the first Copilot event that contains the resumable
      // sessionId. Do not mark the earlier assistant.message as terminal or
      // Minions can resolve before session persistence data is available.
      const finalText = terminalText || copilotMessageBuffer || taskCompleteSummary;
      if (finalText) ctx.setText(finalText);
    }

    if (obj.type === 'session.task_complete') {
      _captureTaskComplete(obj.data?.summary, obj.data?.success);
      return;
    }

    if (obj.type === 'assistant.reasoning' || obj.type === 'assistant.reasoning_delta') {
      ctx.notifyThinking();
      return;
    }

    if (obj.type === 'assistant.message_delta' && typeof obj.data?.deltaContent === 'string') {
      copilotMessageBuffer += obj.data.deltaContent;
      ctx.pushText(copilotMessageBuffer);
      return;
    }

    if (obj.type === 'assistant.message') {
      // Process toolRequests EVEN WHEN data.content is undefined — tool-only
      // assistant messages would otherwise be dropped (earlier review bug:
      // the `typeof data.content === 'string'` gate skipped them entirely).
      const data = obj.data || {};
      const content = data.content;
      const hasTools = _copilotAssistantMessageHasTools(obj);
      if (typeof content === 'string') {
        // Tool-request narration is progress text only — don't let it become
        // the terminal answer. A non-tool assistant.message overrides any
        // streamed deltas (Copilot's authoritative final text for the turn).
        if (content && !hasTools) {
          // Second-and-later terminating message in a single turn: prefix the
          // pushed text with `\n\n` so the dashboard merge sees a clean
          // paragraph boundary instead of welding `<prev>.<next>` together
          // (W-mq1jwwqo000f20d9 — CC chunk welding fix). The first push must
          // NOT be prefixed or every reply starts with a blank line. Tool-use
          // boundaries are skipped — those get rendered as visible chips
          // client-side and already act as natural separators.
          const pushed = _hadPriorAssistantMessage ? '\n\n' + content : content;
          terminalText = _hadPriorAssistantMessage ? terminalText + '\n\n' + content : content;
          ctx.pushText(pushed);
          _hadPriorAssistantMessage = true;
        }
        copilotMessageBuffer = '';
      }
      if (Array.isArray(data.toolRequests)) {
        for (const tr of data.toolRequests) {
          if (!tr || !tr.name) continue;
          if (tr.name === 'task_complete') {
            _captureTaskComplete(tr.arguments?.summary || tr.intentionSummary, true, { clearBuffer: true });
            continue;
          }
          ctx.pushToolUse(tr.name, tr.arguments || {});
        }
      }
      return;
    }

    if (obj.type === 'tool.execution_start' && obj.data?.toolName) {
      if (obj.data.toolName === 'task_complete') {
        _captureTaskComplete(obj.data.arguments?.summary, true, { clearBuffer: true });
        return;
      }
      const name = obj.data.toolName;
      const input = obj.data.arguments || {};
      // Dedup against assistant.message.toolRequests — accumulator tracks
      // the toolUses array and exposes a same-name+input check.
      if (!ctx.toolUseAlreadySeen(name, input)) {
        ctx.pushToolUse(name, input);
      }
    }
  }

  function reset() {
    copilotMessageBuffer = '';
    terminalText = '';
    taskCompleteSummary = '';
    _hadPriorAssistantMessage = false;
  }

  return { consume, reset };
}

// ── Permission Bypass ───────────────────────────────────────────────────────
//
// Flags the engine relies on for headless operation. `preflight.doctor()`
// shells out to `<bin> --help` and verifies each literal appears in the help
// text — surfaces "your CLI is too old / has been renamed" before agents
// silently hang on permission prompts. Must stay in sync with `buildArgs`.
const PERMISSION_BYPASS_FLAGS = ['--autopilot', '--allow-all', '--no-ask-user'];

// ── Capability Block ────────────────────────────────────────────────────────

const capabilities = {
  // JSONL events on stdout per --output-format json
  streaming: true,
  // --resume=<id> resumes a session
  sessionResume: true,
  // Copilot only exposes sessionId on the terminal result event
  midRunSessionId: false,
  // No --system-prompt-file flag — system prompt is merged into stdin
  systemPromptFile: false,
  // --effort low|medium|high|xhigh (no 'max' — adapter maps it)
  effortLevels: true,
  // result.usage carries premiumRequests count, no USD or tokens
  costTracking: false,
  // No 'sonnet'/'opus'/'haiku' shorthand — Copilot expects full model IDs
  modelShorthands: false,
  // GET https://api.githubcopilot.com/models works (verified during spike)
  modelDiscovery: true,
  // Stdin works in non-interactive mode; -p with >32KB hits Windows ARG_MAX
  promptViaArg: false,
  // No --max-budget-usd
  budgetCap: false,
  // No --bare (closest equivalent is --no-custom-instructions, gated separately)
  bareMode: false,
  // No --fallback-model
  fallbackModel: false,
  // Copilot manages session state internally in ~/.copilot/session-state/
  sessionPersistenceControl: false,
  // CC resumes should include recent visible Q&A in stdin because Minions cannot
  // inspect or repair Copilot's opaque session-state store when it drops context.
  resumePromptCarryover: true,
  // Adapter implements createStreamConsumer(ctx) — required by llm.js accumulator
  streamConsumer: true,
};

// Install hint surfaced when `resolveBinary()` returns null. Covers all
// supported install paths so users on any platform see one actionable line.
//
// gh ≥ ~2.90 ships `gh copilot` as a built-in preview command, so the legacy
// `gh extension install github/gh-copilot` flow is no longer required (and is
// in fact rejected by recent gh, since the command is already provided). The
// hint reflects that: get a modern gh, OR install the standalone Copilot CLI.
const INSTALL_HINT = 'install GitHub CLI 2.90+ which ships `gh copilot` built in (WinGet: `winget install --id GitHub.cli`, Homebrew: `brew install gh`, or download from https://cli.github.com), or install the standalone Copilot CLI via `npm i -g @github/copilot`';

function getUserAssetDirs({ homeDir = os.homedir() } = {}) {
  return [
    path.join(homeDir, '.copilot'),
    path.join(homeDir, '.agents'),
  ];
}

// Copilot CLI reads project skills from .github/skills, .claude/skills, AND
// .agents/skills per the official docs (see "Adding agent skills for GitHub
// Copilot CLI"). Listing all three keeps the dashboard accurate and ensures
// spawned Copilot agents receive `--add-dir` for every dir Copilot would
// natively read from.
function getSkillRoots({ homeDir = os.homedir(), project = null } = {}) {
  const roots = [
    { dir: path.join(homeDir, '.copilot', 'skills'), scope: 'copilot' },
    { dir: path.join(homeDir, '.agents', 'skills'), scope: 'agent-skill' },
  ];
  if (project?.localPath) {
    const projectName = project.name || path.basename(project.localPath);
    roots.push(
      { dir: path.join(project.localPath, '.github', 'skills'), scope: 'project', projectName },
      { dir: path.join(project.localPath, '.claude', 'skills'), scope: 'project', projectName },
      { dir: path.join(project.localPath, '.agents', 'skills'), scope: 'project', projectName },
    );
  }
  return roots;
}

function getSkillWriteTargets({ homeDir = os.homedir(), project = null } = {}) {
  const targets = { personal: path.join(homeDir, '.copilot', 'skills') };
  if (project?.localPath) {
    targets.project = path.join(project.localPath, '.github', 'skills');
  }
  return targets;
}

module.exports = {
  name: 'copilot',
  capabilities,
  resolveBinary,
  capsFile: CAPS_FILE,
  listModels,
  modelsCache: MODELS_CACHE,
  // Use the same wrapper as Claude — spawn-agent.js is runtime-agnostic per P-9c4f2d6a
  spawnScript: path.join(ENGINE_DIR, 'spawn-agent.js'),
  installHint: INSTALL_HINT,
  buildSpawnFlags,
  buildArgs,
  buildPrompt,
  getUserAssetDirs,
  getSkillRoots,
  getSkillWriteTargets,
  getResumeSessionId,
  saveSession,
  detectPermissionGate,
  getPromptDeliveryMode,
  usesSystemPromptFile,
  classifyFailure,
  resolveModel,
  modelLooksFamiliar,
  parseOutput,
  parseStreamChunk,
  parseError,
  createStreamConsumer,
  permissionBypassFlags: PERMISSION_BYPASS_FLAGS,
  // Exposed for unit tests — engine code MUST go through resolveRuntime + the
  // adapter contract; never reach into these helpers directly.
  _MINIONS_MODEL_ALIASES,
  _mapEffort,
  _copilotAssistantMessageHasTools,
  _readCatalogIds,
  _hyphenToDotVersion,
  _pickStandaloneCopilotFromOutput,
  _resolveNpmCopilotJsEntry,
  _isCachedBinUsable,
  _findGhCopilotExtension,
  // W-mpmwxni2000c25c7-a — invalid-model error-path helpers. `_warmModelCache`
  // populates the in-memory model catalog so parseError can enrich its
  // "Model X not available" message without a per-error HTTP round trip.
  _warmModelCache,
  _resetModelCache,
  _extractInvalidModelName,
  CAPS_SCHEMA_VERSION,
  KNOWN_EVENT_TYPES,
};
