'use strict';

const fs = require('fs');
const os = require('os');
const path = require('path');
const crypto = require('crypto');

const CACHE_VERSION = 1;
const DEFAULT_AGENTS = Object.freeze(['claude', 'codex', 'gemini', 'cursor-agent']);

function _pathHash(envPath) {
  return crypto.createHash('sha256').update(String(envPath || '')).digest('hex');
}

function _splitPath(envPath) {
  return String(envPath || '')
    .split(path.delimiter)
    .map((entry) => entry || process.cwd())
    .filter(Boolean);
}

// GUI-launched processes (e.g. the packaged "Coding Task Manager" app) inherit
// a minimal PATH that often omits user-local install dirs. cursor-agent (and
// the cursor/claude/codex installers) drop binaries in ~/.local/bin, so a CLI
// installed only there would read as "not detected on PATH" even though it
// works in a normal shell. Always probe these well-known dirs in addition to
// the process PATH so detection — and the absolute-path resolution that powers
// spawning — are independent of how CTM was launched.
function _extraBinDirs() {
  const home = os.homedir();
  if (!home) return ['/opt/homebrew/bin', '/usr/local/bin'];
  return [
    path.join(home, '.local', 'bin'),
    path.join(home, 'bin'),
    '/opt/homebrew/bin',
    '/usr/local/bin',
  ];
}

function _candidateBinDirs(envPath, extraDirs) {
  const extras = Array.isArray(extraDirs) ? extraDirs : _extraBinDirs();
  const seen = new Set();
  const dirs = [];
  for (const dir of [..._splitPath(envPath), ...extras]) {
    if (dir && !seen.has(dir)) { seen.add(dir); dirs.push(dir); }
  }
  return dirs;
}

function _isExecutable(filePath) {
  try {
    fs.accessSync(filePath, fs.constants.X_OK);
    return true;
  } catch {
    return false;
  }
}

function _statFingerprint(filePath) {
  try {
    if (!_isExecutable(filePath)) return null;
    const stat = fs.statSync(filePath);
    const lstat = fs.lstatSync(filePath);
    return {
      path: filePath,
      realpath: fs.realpathSync.native ? fs.realpathSync.native(filePath) : fs.realpathSync(filePath),
      stat: {
        dev: stat.dev,
        ino: stat.ino,
        mode: stat.mode,
        size: stat.size,
        mtimeMs: stat.mtimeMs,
        ctimeMs: stat.ctimeMs,
      },
      lstat: {
        dev: lstat.dev,
        ino: lstat.ino,
        mode: lstat.mode,
        size: lstat.size,
        mtimeMs: lstat.mtimeMs,
        ctimeMs: lstat.ctimeMs,
      },
    };
  } catch {
    return null;
  }
}

function _sameFingerprint(a, b) {
  if (!a || !b) return false;
  return a.path === b.path &&
    a.realpath === b.realpath &&
    a.stat?.dev === b.stat?.dev &&
    a.stat?.ino === b.stat?.ino &&
    a.stat?.mode === b.stat?.mode &&
    a.stat?.size === b.stat?.size &&
    a.stat?.mtimeMs === b.stat?.mtimeMs &&
    a.stat?.ctimeMs === b.stat?.ctimeMs &&
    a.lstat?.dev === b.lstat?.dev &&
    a.lstat?.ino === b.lstat?.ino &&
    a.lstat?.mode === b.lstat?.mode &&
    a.lstat?.size === b.lstat?.size &&
    a.lstat?.mtimeMs === b.lstat?.mtimeMs &&
    a.lstat?.ctimeMs === b.lstat?.ctimeMs;
}

function _findOnPath(bin, envPath, extraDirs) {
  for (const dir of _candidateBinDirs(envPath, extraDirs)) {
    const candidate = path.resolve(dir, bin);
    const fingerprint = _statFingerprint(candidate);
    if (fingerprint) return fingerprint;
  }
  return null;
}

function _readCache(cacheFile) {
  try {
    const cache = JSON.parse(fs.readFileSync(cacheFile, 'utf8'));
    if (cache && cache.version === CACHE_VERSION && cache.agents && typeof cache.agents === 'object') return cache;
  } catch {}
  return { version: CACHE_VERSION, generatedAt: 0, agents: {} };
}

function _writeCache(cacheFile, cache) {
  try {
    fs.mkdirSync(path.dirname(cacheFile), { recursive: true });
    fs.writeFileSync(cacheFile, JSON.stringify(cache, null, 2));
    try { fs.chmodSync(cacheFile, 0o600); } catch {}
  } catch (e) {
    console.error('[agent-cli-cache] failed to write cache:', e.message);
  }
}

function _entryFromFingerprint(agent, fingerprint, envPath, now, source) {
  return {
    agent,
    bin: agent,
    available: !!fingerprint,
    path: fingerprint?.path || null,
    realpath: fingerprint?.realpath || null,
    pathHash: _pathHash(envPath),
    detectedAt: now,
    source,
    fingerprint: fingerprint || null,
  };
}

function _isCachedEntryFresh(entry, envPath) {
  if (!entry || !entry.available || !entry.path || entry.pathHash !== _pathHash(envPath)) return false;
  return _sameFingerprint(entry.fingerprint, _statFingerprint(entry.path));
}

function _publicEntry(entry, envPath) {
  if (!entry) return { available: false, path: null, realpath: null, source: 'missing-cache' };
  const fresh = entry.available ? _isCachedEntryFresh(entry, envPath) : false;
  return {
    available: !!entry.available,
    path: entry.path || null,
    realpath: entry.realpath || null,
    detectedAt: entry.detectedAt || null,
    source: entry.source || 'cache',
    stale: !!entry.available && !fresh,
  };
}

function discoverAgent(agent, { env = process.env, now = Date.now(), extraBinDirs } = {}) {
  const envPath = env.PATH || process.env.PATH || '';
  return _entryFromFingerprint(agent, _findOnPath(agent, envPath, extraBinDirs), envPath, now, 'path-scan');
}

function getAvailableAgents({
  cacheFile = path.join(os.homedir(), '.walle', 'data', 'agent-cli-cache.json'),
  agents = DEFAULT_AGENTS,
  env = process.env,
  now = Date.now(),
  extraBinDirs,
} = {}) {
  const envPath = env.PATH || process.env.PATH || '';
  const cache = _readCache(cacheFile);
  let changed = false;
  const result = {
    ok: true,
    cacheFile,
    generatedAt: now,
    agents: {},
  };

  for (const agent of agents) {
    const cached = cache.agents[agent];
    let entry = cached;
    if (!_isCachedEntryFresh(cached, envPath)) {
      entry = discoverAgent(agent, { env, now, extraBinDirs });
      cache.agents[agent] = entry;
      changed = true;
    }
    result.agents[agent] = _publicEntry(entry, envPath);
    result[agent] = !!entry.available;
  }

  if (changed || !cache.generatedAt) {
    cache.version = CACHE_VERSION;
    cache.generatedAt = now;
    _writeCache(cacheFile, cache);
  }
  return result;
}

function getCachedAvailableAgents({
  cacheFile = path.join(os.homedir(), '.walle', 'data', 'agent-cli-cache.json'),
  agents = DEFAULT_AGENTS,
  env = process.env,
} = {}) {
  const envPath = env.PATH || process.env.PATH || '';
  const cache = _readCache(cacheFile);
  const result = { ok: true, cacheFile, generatedAt: cache.generatedAt || 0, agents: {} };
  for (const agent of agents) {
    const entry = cache.agents[agent];
    result.agents[agent] = _publicEntry(entry, envPath);
    result[agent] = !!entry?.available && !result.agents[agent].stale;
  }
  return result;
}

function resolveCliCommand(cmd, {
  cacheFile = path.join(os.homedir(), '.walle', 'data', 'agent-cli-cache.json'),
  agents = DEFAULT_AGENTS,
  env = process.env,
  now = Date.now(),
  extraBinDirs,
} = {}) {
  if (!cmd || typeof cmd !== 'string') return cmd;
  const base = path.basename(cmd);
  if (!agents.includes(base)) return cmd;

  // Absolute or relative paths are already explicit. Use them when valid; if
  // a persisted CTM task points at a removed CLI path, recover via PATH.
  if (cmd.includes('/') && _statFingerprint(cmd)) return cmd;

  const available = getAvailableAgents({ cacheFile, agents, env, now, extraBinDirs });
  const entry = available.agents[base];
  return entry?.available && entry.path ? entry.path : cmd;
}

module.exports = {
  CACHE_VERSION,
  DEFAULT_AGENTS,
  discoverAgent,
  getAvailableAgents,
  getCachedAvailableAgents,
  resolveCliCommand,
  _pathHash,
  _findOnPath,
  _statFingerprint,
};
