// Idempotent installer/uninstaller for agent-CLI hooks + OTEL config.
//
// CTM needs three agent config surfaces patched so state signals flow back to
// its HTTP port:
//
//   1. ~/.claude/settings.json  — JSON hooks that run our bin/claude-hook.js
//   2. ~/.codex/config.toml     — [otel] block + [notify] block (custom TOML parser)
//   3. ~/.gemini/settings.json  — JSON hooks that run our bin/gemini-hook.js
//
// Cursor is also a first-class coding-agent runtime, but it does not currently
// expose a documented hook config surface. Its live status comes from CTM's
// managed terminal lifecycle, so install/uninstall are no-op plans instead of
// writing speculative keys into ~/.cursor/cli-config.json.
//
// Every write is idempotent: re-running install() is a no-op if the marker
// strings (identifying our hook entries) are already present. uninstall()
// removes only our entries and leaves everything else intact.
//
// All mutations go through a plan→apply split so UIs can preview the diff
// before confirming — see planInstall() / planUninstall() for read-only
// variants that return { changes, willWrite, currentText, nextText } without
// touching the filesystem.

const fs = require('fs');
const path = require('path');
const os = require('os');
const { getPreset, PRESETS } = require('./agent-presets');

const CTM_ROOT = path.resolve(__dirname, '..');
const HOOK_BIN_DIR = path.join(CTM_ROOT, 'bin');

/** Expand ~/ to the user's home directory. */
function expandHome(p) {
  if (!p) return p;
  if (p === '~') return os.homedir();
  if (p.startsWith('~/')) return path.join(os.homedir(), p.slice(2));
  return p;
}

/**
 * Build the shell command for a hook entry. We use `node <abs-path-to-ctm>/bin/<bin>.js <port> <route>`
 * so there's no PATH dependency (hooks might run from any cwd).
 */
function buildHookCommand(bin, port, route) {
  const binPath = path.join(HOOK_BIN_DIR, `${bin}.js`);
  return `node ${JSON.stringify(binPath)} ${port} ${route}`;
}

/** Marker used to recognize our own hook entries on uninstall (survives JSON roundtrip). */
function ctmMarker(port) {
  return `ctm:${port}:${HOOK_BIN_DIR}`;
}

function planTerminalManagedAgent(agent, preset) {
  return {
    agent,
    filePath: expandHome(preset?.configPath || ''),
    format: preset?.configFormat || 'none',
    currentText: '',
    nextText: '',
    willWrite: false,
    changes: [
      { description: 'No config write: CTM tracks this runtime through the managed terminal lifecycle.' },
    ],
  };
}

// ─── Claude settings.json (JSON, array-of-hooks shape) ────────────────────

/**
 * Read-and-parse ~/.claude/settings.json. Returns {} if the file doesn't
 * exist (fine — Claude creates on first run). Throws on corrupt JSON.
 */
/**
 * Robust read: returns '' if file is missing. Throws a clear error if the file
 * exists but is unreadable (common case on this machine: Dropbox placeholder
 * throws EDEADLK because the file has a materialization flag set).
 */
function safeReadFile(p) {
  if (!fs.existsSync(p)) return '';
  try { return fs.readFileSync(p, 'utf8'); }
  catch (e) {
    const materializeProblem = e.code === 'EDEADLK' || e.errno === -11 || /deadlock|system error -11/i.test(e.message || '');
    const hint = materializeProblem
      ? ' (file appears to be a cloud-storage placeholder or dataless file — make it available offline, then retry)'
      : '';
    throw new Error(`cannot read ${p}: ${e.message}${hint}`);
  }
}

function readClaudeSettings() {
  const p = expandHome('~/.claude/settings.json');
  const rawText = safeReadFile(p);
  if (!rawText) return { __path: p, data: {}, rawText: '' };
  let data;
  try { data = JSON.parse(rawText); }
  catch (e) { throw new Error(`~/.claude/settings.json is not valid JSON: ${e.message}`); }
  return { __path: p, data, rawText };
}

/**
 * Produce the JSON payload for Claude's settings.json after install.
 * Claude's hook schema:
 *   hooks: {
 *     SessionStart: [ { matcher?: string, hooks: [ { type: 'command', command: '...' } ] } ],
 *     ...
 *   }
 */
function patchClaudeSettings(data, port) {
  const marker = ctmMarker(port);
  const preset = getPreset('claude-code');
  const next = { ...(data || {}) };
  next.hooks = { ...(next.hooks || {}) };
  for (const h of preset.hooks) {
    const command = buildHookCommand(h.bin, port, h.route);
    const hookEntry = {
      ...(h.matcher ? { matcher: h.matcher } : {}),
      hooks: [{ type: 'command', command, timeout: 5, __ctmMarker: marker }],
    };
    next.hooks[h.kind] = (next.hooks[h.kind] || []).filter((e) => {
      // drop any pre-existing CTM entry (idempotency) but keep user's own
      return !(e && Array.isArray(e.hooks) && e.hooks.some((hk) => hk?.__ctmMarker === marker));
    });
    next.hooks[h.kind].push(hookEntry);
  }
  return next;
}

function unpatchClaudeSettings(data, port) {
  const marker = ctmMarker(port);
  const next = { ...(data || {}) };
  if (!next.hooks) return next;
  const hooksOut = {};
  for (const [kind, entries] of Object.entries(next.hooks)) {
    if (!Array.isArray(entries)) { hooksOut[kind] = entries; continue; }
    const keep = entries.filter((e) => !(e && Array.isArray(e.hooks) && e.hooks.some((hk) => hk?.__ctmMarker === marker)));
    if (keep.length) hooksOut[kind] = keep;
  }
  if (Object.keys(hooksOut).length) next.hooks = hooksOut; else delete next.hooks;
  return next;
}

// ─── Gemini settings.json (same JSON shape as Claude) ─────────────────────

function readGeminiSettings() {
  const p = expandHome('~/.gemini/settings.json');
  const rawText = safeReadFile(p);
  if (!rawText) return { __path: p, data: {}, rawText: '' };
  let data;
  try { data = JSON.parse(rawText); }
  catch (e) { throw new Error(`~/.gemini/settings.json is not valid JSON: ${e.message}`); }
  return { __path: p, data, rawText };
}

function patchGeminiSettings(data, port) {
  const marker = ctmMarker(port);
  const preset = getPreset('gemini-cli');
  const next = { ...(data || {}) };
  next.hooks = { ...(next.hooks || {}) };
  for (const h of preset.hooks) {
    const command = buildHookCommand(h.bin, port, h.route);
    const entry = { type: 'command', command, timeout: 5, __ctmMarker: marker };
    next.hooks[h.kind] = (next.hooks[h.kind] || []).filter((e) => e?.__ctmMarker !== marker);
    next.hooks[h.kind].push(entry);
  }
  return next;
}

function unpatchGeminiSettings(data, port) {
  const marker = ctmMarker(port);
  const next = { ...(data || {}) };
  if (!next.hooks) return next;
  const out = {};
  for (const [kind, entries] of Object.entries(next.hooks)) {
    if (!Array.isArray(entries)) { out[kind] = entries; continue; }
    const keep = entries.filter((e) => e?.__ctmMarker !== marker);
    if (keep.length) out[kind] = keep;
  }
  if (Object.keys(out).length) next.hooks = out; else delete next.hooks;
  return next;
}

// ─── Codex config.toml (custom narrow TOML section patcher) ───────────────
// We deliberately avoid pulling a full TOML parser — Codex config is flat enough
// that section-header + body replacement handles the cases we care about:
// adding/replacing the [otel] and [notify] sections. Port of the techniques in
// /tmp/clideck/codex-config.js (77 LOC; we reuse the approach, not the bits).

function readCodexConfig() {
  const p = expandHome('~/.codex/config.toml');
  return { __path: p, text: safeReadFile(p) };
}

function parseTomlDottedKeySegments(value) {
  const src = String(value || '');
  const segments = [];
  let i = 0;
  const skipWs = () => {
    while (i < src.length && /\s/.test(src[i])) i++;
  };

  while (i < src.length) {
    skipWs();
    if (i >= src.length) break;

    const quote = src[i] === '"' || src[i] === "'" ? src[i++] : '';
    if (quote) {
      let segment = '';
      let closed = false;
      while (i < src.length) {
        const ch = src[i++];
        if (quote === '"' && ch === '\\' && i < src.length) {
          segment += src[i++];
          continue;
        }
        if (ch === quote) {
          closed = true;
          break;
        }
        segment += ch;
      }
      if (!closed) return null;
      segments.push(segment);
    } else {
      const start = i;
      while (i < src.length && src[i] !== '.') i++;
      const segment = src.slice(start, i).trim();
      if (!segment) return null;
      segments.push(segment);
    }

    skipWs();
    if (i >= src.length) break;
    if (src[i] !== '.') return null;
    i++;
  }

  return segments.length ? segments : null;
}

function parseTomlTableHeader(line) {
  const trimmed = String(line || '').trim();
  if (!trimmed.startsWith('[')) return null;
  const isArray = trimmed.startsWith('[[');
  const openLen = isArray ? 2 : 1;
  const closeLen = isArray ? 2 : 1;
  let quote = '';
  let escaped = false;
  let end = -1;

  for (let i = openLen; i < trimmed.length; i++) {
    const ch = trimmed[i];
    if (quote) {
      if (quote === '"' && escaped) {
        escaped = false;
        continue;
      }
      if (quote === '"' && ch === '\\') {
        escaped = true;
        continue;
      }
      if (ch === quote) quote = '';
      continue;
    }
    if (ch === '"' || ch === "'") {
      quote = ch;
      continue;
    }
    if (isArray ? (ch === ']' && trimmed[i + 1] === ']') : ch === ']') {
      end = i;
      break;
    }
  }
  if (end < 0) return null;

  const rest = trimmed.slice(end + closeLen).trim();
  if (rest && !rest.startsWith('#')) return null;
  const segments = parseTomlDottedKeySegments(trimmed.slice(openLen, end));
  return segments ? { segments, isArray } : null;
}

function tomlHeaderBelongsToSection(header, sectionName) {
  const target = String(sectionName || '').trim();
  return !!target && !!header && Array.isArray(header.segments) && header.segments[0] === target;
}

/**
 * Remove a TOML section tree (e.g. [otel] and [otel.exporter]) and its body.
 * Stops at the next table whose first dotted-key segment is not sectionName,
 * including quoted/dotted headers such as [mcp_servers."wall-e"].
 */
function stripTomlSection(text, sectionName) {
  const lines = text.split('\n');
  const out = [];
  let skipping = false;
  for (const line of lines) {
    const header = parseTomlTableHeader(line);
    if (header && tomlHeaderBelongsToSection(header, sectionName)) {
      skipping = true;
      continue;
    }
    if (skipping && header) {
      skipping = false;
    }
    if (!skipping) out.push(line);
  }
  // Remove trailing blank lines the strip left behind
  while (out.length && out[out.length - 1].trim() === '') out.pop();
  return out.join('\n') + (out.length ? '\n' : '');
}

/** Strip our marker-comment lines (left behind by earlier versions of the patcher). */
function stripCtmMarkerLines(text) {
  return text
    .split('\n')
    .filter((line) => !/^\s*#\s*---\s*ctm-hooks-(begin|end)\s*---\s*$/.test(line))
    .join('\n');
}

/**
 * Remove a top-level scalar/array assignment like `notify = [...]` (one
 * logical line, possibly continuation lines for multi-line arrays). Used
 * for the `notify` key which Codex expects as a top-level array, not a
 * `[notify]` section.
 */
function stripTomlScalarKey(text, keyName) {
  const lines = text.split('\n');
  const out = [];
  const startRe = new RegExp(`^\\s*${keyName}\\s*=`);
  let inMultiline = false;
  let openBracket = '';
  for (const line of lines) {
    if (!inMultiline && startRe.test(line)) {
      // Detect a multi-line value starting on this line (opening [ without closing ]).
      const opens = (line.match(/\[/g) || []).length;
      const closes = (line.match(/\]/g) || []).length;
      if (opens > closes) { inMultiline = true; openBracket = '['; }
      continue;
    }
    if (inMultiline) {
      // Eat continuation lines until balanced brackets.
      const opens = (line.match(/\[/g) || []).length;
      const closes = (line.match(/\]/g) || []).length;
      // crude balance: assume the multi-line value ends when we see a `]`
      // at start (or any line containing the matching count). Codex notify
      // is single-line in practice so this is mostly defensive.
      if (closes >= opens) inMultiline = false;
      continue;
    }
    out.push(line);
  }
  while (out.length && out[out.length - 1].trim() === '') out.pop();
  return out.join('\n') + (out.length ? '\n' : '');
}

/** Append a fresh [otel] section + top-level `notify = [...]` array
 *  targeting CTM at <port>. Codex's notify config is a TOP-LEVEL ARRAY,
 *  NOT a [notify] section — getting this wrong fails Codex startup with
 *  `invalid type: map, expected a sequence in 'notify'`. */
function appendCodexSections(text, port) {
  const binPath = path.join(HOOK_BIN_DIR, 'codex-notify.js');
  const binJson = JSON.stringify(binPath);  // quotes for safety
  // Collapse trailing blank lines so successive install calls produce the same bytes.
  let trimmed = text.replace(/\n+$/, '');
  if (trimmed) trimmed += '\n';
  const blocks = [
    '',
    '[otel]',
    `exporter = { otlp-http = { endpoint = "http://localhost:${port}", protocol = "json" } }`,
    '',
    // Codex 0.x: `notify` is a top-level array of program + args.
    // See https://github.com/openai/codex docs and serde error message.
    `notify = ["node", ${binJson}, "${port}", "stop"]`,
    '',
  ].join('\n');
  return trimmed + blocks;
}

function patchCodexConfig(text, port) {
  // Strip everything we might have written previously:
  //   - [otel] section (our own)
  //   - [notify] section (legacy buggy format that Codex now rejects)
  //   - notify = ... top-level array (correct format — strip + re-append for idempotence)
  //   - any CTM marker comments left over from older installer versions
  const cleaned = stripCtmMarkerLines(
    stripTomlScalarKey(
      stripTomlSection(stripTomlSection(text || '', 'otel'), 'notify'),
      'notify',
    )
  );
  return appendCodexSections(cleaned, port);
}

function unpatchCodexConfig(text, _port) {
  return stripCtmMarkerLines(
    stripTomlScalarKey(
      stripTomlSection(stripTomlSection(text || '', 'otel'), 'notify'),
      'notify',
    )
  );
}

// ─── Unified plan/apply API ───────────────────────────────────────────────

/**
 * Produce a plan for installing hooks for a given agent. Pure — no I/O writes.
 * Returns:
 *   {
 *     agent, filePath, format, willWrite, currentText, nextText,
 *     changes: [ { description, line? } ... ]  // free-form diff for the UI
 *   }
 */
function planInstall(agent, port) {
  const preset = getPreset(agent);
  if (!preset) throw new Error(`Unknown agent preset: ${agent}`);
  if (preset.liveStatusMode === 'terminal') return planTerminalManagedAgent(agent, preset);
  if (agent === 'claude-code') {
    const { __path, data, rawText } = readClaudeSettings();
    const next = patchClaudeSettings(data, port);
    const nextText = JSON.stringify(next, null, 2) + '\n';
    return {
      agent, filePath: __path, format: 'json',
      currentText: rawText, nextText,
      willWrite: rawText !== nextText,
      changes: preset.hooks.map((h) => ({
        description: `hook: ${h.kind}${h.matcher ? ` (matcher ${h.matcher})` : ''} → bin/${h.bin}.js`,
      })),
    };
  }
  if (agent === 'gemini-cli') {
    const { __path, data, rawText } = readGeminiSettings();
    const next = patchGeminiSettings(data, port);
    const nextText = JSON.stringify(next, null, 2) + '\n';
    return {
      agent, filePath: __path, format: 'json',
      currentText: rawText, nextText,
      willWrite: rawText !== nextText,
      changes: preset.hooks.map((h) => ({
        description: `hook: ${h.kind} → bin/${h.bin}.js`,
      })),
    };
  }
  if (agent === 'codex') {
    const { __path, text } = readCodexConfig();
    const nextText = patchCodexConfig(text, port);
    return {
      agent, filePath: __path, format: 'toml',
      currentText: text, nextText,
      willWrite: text !== nextText,
      changes: [
        { description: `[otel] block → http://localhost:${port} (OTLP/HTTP JSON)` },
        { description: `notify array → bin/codex-notify.js` },
      ],
    };
  }
  throw new Error(`No planInstall for ${agent}`);
}

function planUninstall(agent, port) {
  const preset = getPreset(agent);
  if (!preset) throw new Error(`Unknown agent preset: ${agent}`);
  if (preset.liveStatusMode === 'terminal') return planTerminalManagedAgent(agent, preset);
  if (agent === 'claude-code') {
    const { __path, data, rawText } = readClaudeSettings();
    const next = unpatchClaudeSettings(data, port);
    const nextText = JSON.stringify(next, null, 2) + '\n';
    return { agent, filePath: __path, format: 'json', currentText: rawText, nextText, willWrite: rawText !== nextText, changes: [{ description: 'Remove CTM hook entries' }] };
  }
  if (agent === 'gemini-cli') {
    const { __path, data, rawText } = readGeminiSettings();
    const next = unpatchGeminiSettings(data, port);
    const nextText = JSON.stringify(next, null, 2) + '\n';
    return { agent, filePath: __path, format: 'json', currentText: rawText, nextText, willWrite: rawText !== nextText, changes: [{ description: 'Remove CTM hook entries' }] };
  }
  if (agent === 'codex') {
    const { __path, text } = readCodexConfig();
    const nextText = unpatchCodexConfig(text, port);
    return { agent, filePath: __path, format: 'toml', currentText: text, nextText, willWrite: text !== nextText, changes: [{ description: 'Remove [otel] + notify entries' }] };
  }
  throw new Error(`No planUninstall for ${agent}`);
}

/** Apply a plan (install or uninstall). Creates parent dirs if needed. Idempotent.
 *  Avoids creating empty stub files — if the current file didn't exist and the
 *  target content is empty-JSON ("{}" + "\n") or pure whitespace, skip the write. */
function applyPlan(plan) {
  if (!plan || !plan.filePath) throw new Error('invalid plan');
  if (!plan.willWrite) return { written: false, filePath: plan.filePath };
  // Don't create a file that's empty-equivalent on a system that didn't have one before.
  const looksEmpty = !plan.nextText.trim() || plan.nextText.trim() === '{}';
  if (!plan.currentText && looksEmpty) {
    return { written: false, filePath: plan.filePath, skipped: 'empty-noop' };
  }
  const dir = path.dirname(plan.filePath);
  if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
  fs.writeFileSync(plan.filePath, plan.nextText, 'utf8');
  return { written: true, filePath: plan.filePath, bytes: plan.nextText.length };
}

/** Convenience for tests / CLI: install = plan + apply. */
function install(agent, port) { return applyPlan(planInstall(agent, port)); }
function uninstall(agent, port) { return applyPlan(planUninstall(agent, port)); }

/** Is this agent's config currently patched with our marker (for this port)? */
function isInstalled(agent, port) {
  const preset = getPreset(agent);
  if (preset?.liveStatusMode === 'terminal') return true;
  const marker = ctmMarker(port);
  try {
    if (agent === 'claude-code') {
      const { rawText } = readClaudeSettings();
      return rawText.includes(marker);
    }
    if (agent === 'gemini-cli') {
      const { rawText } = readGeminiSettings();
      return rawText.includes(marker);
    }
    if (agent === 'codex') {
      const { text } = readCodexConfig();
      // Codex config is TOML — we key on our own binary path + the OTEL endpoint port
      // to avoid false positives from user-owned [otel] blocks pointing elsewhere.
      return text.includes(`localhost:${port}`) && text.includes('codex-notify.js');
    }
  } catch { return false; }
  return false;
}

module.exports = {
  PRESETS, buildHookCommand, ctmMarker,
  planInstall, planUninstall, applyPlan,
  install, uninstall, isInstalled,
  // Exported for unit-test coverage of the TOML patcher
  _internals: { stripTomlSection, appendCodexSections, patchClaudeSettings, unpatchClaudeSettings, patchGeminiSettings, unpatchGeminiSettings, patchCodexConfig, unpatchCodexConfig },
};
