/**
 * `mcpctl chat <agent>` — interactive REPL + one-shot mode.
 *
 * Streams the agent's response over SSE so the user sees text appear as it's
 * generated. Tool calls and tool results print to stderr in dim style so the
 * REPL output stays clean. LiteLLM-style flags (--temperature, --max-tokens,
 * --system, etc.) override the agent's defaultParams for this session only;
 * use the in-REPL `/save` slash-command to persist them back to the agent.
 *
 * Modes:
 *   mcpctl chat <agent>                 # REPL, new thread
 *   mcpctl chat <agent> --thread <id>   # REPL, resume thread
 *   mcpctl chat <agent> -m "hi"         # one-shot, prints reply, no REPL
 *
 * Slash-commands inside the REPL:
 *   /set KEY VALUE                      # adjust an override (temperature 0.2)
 *   /system <text>                      # set systemAppend for this turn onward
 *   /tools                              # list tools the agent can call
 *   /clear                              # start a fresh thread (same agent)
 *   /save                               # PATCH agent.defaultParams = current overrides
 *   /quit                               # exit
 */
import { Command } from 'commander';
import http from 'node:http';
import https from 'node:https';
import readline from 'node:readline';
import { promises as fs } from 'node:fs';
import type { ApiClient } from '../api-client.js';

const STREAM_TIMEOUT_MS = 600_000; // 10 minutes — agent turns can include long tool calls

export interface ChatCommandDeps {
  client: ApiClient;
  baseUrl: string;
  token?: string | undefined;
  log: (...args: unknown[]) => void;
}

export function createChatCommand(deps: ChatCommandDeps): Command {
  return new Command('chat')
    .description('Open an interactive chat session with an agent or a project (REPL or one-shot).')
    .argument('[agent]', 'Agent name (see `mcpctl get agents`). Omit and use --project to chat with a project.')
    .option('-p, --project <name>', 'Chat scoped to a project: its prompts, tools, and (with --allow-secrets) secrets')
    .option('--allow-secrets', 'Project chat: let the model read secret values via the get_secret tool (needs view:secrets)')
    .option('-m, --message <text>', 'One-shot: send a single message and exit (no REPL)')
    .option('--thread <id>', 'Resume an existing thread')
    .option('--system <text>', 'Replace the base system prompt for this session')
    .option('--system-file <path>', 'Read --system text from a file')
    .option('--system-append <text>', 'Append to the system block for this session')
    .option('--personality <name>', 'Personality overlay (agent chat only)')
    .option('--temperature <n>', 'Sampling temperature (0..2)', parseFloat)
    .option('--top-p <n>', 'Nucleus sampling cutoff (0..1)', parseFloat)
    .option('--top-k <n>', 'Top-K sampling (Anthropic; OpenAI ignores)', parseFloatInt)
    .option('--max-tokens <n>', 'Maximum tokens in the assistant reply', parseFloatInt)
    .option('--seed <n>', 'Reproducibility seed (provider-dependent)', parseFloatInt)
    .option('--stop <text>', 'Stop sequence (repeatable)', collect, [])
    .option('--allow-tool <name>', 'Restrict to this tool only (repeatable)', collect, [])
    .option('--extra <kv>', 'Provider-specific knob k=v (repeatable)', collect, [])
    .option('--no-stream', 'Disable SSE streaming (single JSON response)')
    .action(async (agent: string | undefined, opts: ChatOpts) => {
      const subject = resolveSubject(agent, opts);
      const overrides = await buildInitialOverrides(opts);

      if (opts.message !== undefined) {
        await runOneShot(deps, subject, opts.message, opts.thread, overrides, opts.stream);
        return;
      }
      await runRepl(deps, subject, opts.thread, overrides, opts.stream);
    });
}

/** What the chat is bound to: a named Agent or a Project. */
interface ChatSubject {
  kind: 'agent' | 'project';
  name: string;
  /** URL segment, e.g. `agents/reviewer` or `projects/sre` (name url-encoded). */
  path: string;
  /** Project chat only: opt into the gated get_secret tool. */
  allowSecrets: boolean;
}

function resolveSubject(agent: string | undefined, opts: ChatOpts): ChatSubject {
  if (opts.project !== undefined && agent !== undefined) {
    throw new Error('Pass either an <agent> or --project <name>, not both.');
  }
  if (opts.project !== undefined) {
    return { kind: 'project', name: opts.project, path: `projects/${encodeURIComponent(opts.project)}`, allowSecrets: opts.allowSecrets === true };
  }
  if (agent !== undefined) {
    return { kind: 'agent', name: agent, path: `agents/${encodeURIComponent(agent)}`, allowSecrets: false };
  }
  throw new Error('Specify an <agent> name or --project <name>. See `mcpctl chat --help`.');
}

/**
 * Build the chat request body for a subject. Project chat omits the agent-only
 * `personality` overlay (the project schema rejects unknown fields) and adds
 * `allowSecrets` when requested.
 */
function chatBody(subject: ChatSubject, message: string, threadId: string | undefined, overrides: Overrides, stream?: boolean): Record<string, unknown> {
  const o: Record<string, unknown> = { ...overrides };
  if (subject.kind === 'project') {
    delete o.personality;
    if (subject.allowSecrets) o.allowSecrets = true;
  }
  const body: Record<string, unknown> = { message, ...o };
  if (threadId !== undefined) body.threadId = threadId;
  if (stream === true) body.stream = true;
  return body;
}

interface ChatOpts {
  project?: string;
  allowSecrets?: boolean;
  message?: string;
  thread?: string;
  system?: string;
  systemFile?: string;
  systemAppend?: string;
  personality?: string;
  temperature?: number;
  topP?: number;
  topK?: number;
  maxTokens?: number;
  seed?: number;
  stop?: string[];
  allowTool?: string[];
  extra?: string[];
  stream?: boolean;
}

interface Overrides {
  systemOverride?: string;
  systemAppend?: string;
  personality?: string;
  temperature?: number;
  top_p?: number;
  top_k?: number;
  max_tokens?: number;
  seed?: number;
  stop?: string[];
  tools_allowlist?: string[];
  extra?: Record<string, unknown>;
}

async function buildInitialOverrides(opts: ChatOpts): Promise<Overrides> {
  const out: Overrides = {};
  let system = opts.system;
  if (system === undefined && opts.systemFile !== undefined) {
    system = (await fs.readFile(opts.systemFile, 'utf-8')).trim();
  }
  if (system !== undefined) out.systemOverride = system;
  if (opts.systemAppend !== undefined) out.systemAppend = opts.systemAppend;
  if (opts.personality !== undefined) out.personality = opts.personality;
  if (opts.temperature !== undefined) out.temperature = opts.temperature;
  if (opts.topP !== undefined) out.top_p = opts.topP;
  if (opts.topK !== undefined) out.top_k = opts.topK;
  if (opts.maxTokens !== undefined) out.max_tokens = opts.maxTokens;
  if (opts.seed !== undefined) out.seed = opts.seed;
  if (opts.stop !== undefined && opts.stop.length > 0) out.stop = opts.stop;
  if (opts.allowTool !== undefined && opts.allowTool.length > 0) out.tools_allowlist = opts.allowTool;
  if (opts.extra !== undefined && opts.extra.length > 0) {
    const extra: Record<string, unknown> = {};
    for (const kv of opts.extra) {
      const eq = kv.indexOf('=');
      if (eq < 1) throw new Error(`--extra '${kv}' must be key=value`);
      extra[kv.slice(0, eq)] = parseExtraValue(kv.slice(eq + 1));
    }
    out.extra = extra;
  }
  return out;
}

function parseExtraValue(raw: string): unknown {
  if (raw === 'true') return true;
  if (raw === 'false') return false;
  if (raw === 'null') return null;
  if (/^-?\d+(\.\d+)?$/.test(raw)) return Number(raw);
  return raw;
}

async function runOneShot(
  deps: ChatCommandDeps,
  subject: ChatSubject,
  message: string,
  threadId: string | undefined,
  overrides: Overrides,
  stream: boolean | undefined,
): Promise<void> {
  await printChatHeader(deps, subject, overrides);
  if (stream === false) {
    const body = chatBody(subject, message, threadId, overrides);
    const startMs = Date.now();
    const res = await chatRequestNonStream(deps, subject, body);
    const sec = Math.max(0.05, (Date.now() - startMs) / 1000);
    const words = (res.assistant.match(/\S+/g) ?? []).length;
    process.stdout.write(`${res.assistant}\n`);
    // `thread: <id>` — single space after the colon, matching the streaming
    // path (line 160 below) so any tooling/regex that watches one form picks
    // up the other too.
    const answered = formatAnswered(res.llm, res.model, res.failedOver);
    process.stderr.write(styleStats(`(${String(words)}w · ${(words / sec).toFixed(1)} w/s · ${sec.toFixed(1)}s)`) + `  thread: ${res.threadId}${answered !== '' ? `  ${answered}` : ''}\n`);
    return;
  }
  const bar = installStatusBar();
  try {
    const finalThread = await streamOnce(deps, subject, message, threadId, overrides, bar);
    process.stderr.write(`\n(thread: ${finalThread})\n`);
  } finally {
    bar?.teardown();
  }
}

async function runRepl(
  deps: ChatCommandDeps,
  subject: ChatSubject,
  initialThread: string | undefined,
  initialOverrides: Overrides,
  stream: boolean | undefined,
): Promise<void> {
  const overrides: Overrides = { ...initialOverrides };
  let threadId = initialThread;
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
  const ask = (q: string): Promise<string> => new Promise((resolve) => rl.question(q, resolve));

  await printChatHeader(deps, subject, overrides);

  // The status bar persists across turns inside a REPL — it shows the last
  // response's final rate between messages, then refreshes live during the
  // next stream. Only enabled for streaming mode (no rate to show otherwise).
  const bar = stream === false ? null : installStatusBar();

  process.stderr.write(`Slash commands: /threads /resume /delete /set /system /tools /clear /save /quit. Ctrl-D to exit.\n`);
  if (threadId !== undefined) {
    process.stderr.write(`(resuming thread ${threadId})\n`);
  }

  try {
    while (true) {
      let line: string;
      try {
        line = await ask('> ');
      } catch {
        break;
      }
      if (line === '') continue;
      if (line.startsWith('/')) {
        const handled = await handleSlash(line, deps, subject, overrides, {
          get: () => threadId,
          set: (id) => { threadId = id; },
        });
        if (handled === 'quit') break;
        continue;
      }

      try {
        if (stream === false) {
          const body = chatBody(subject, line, threadId, overrides);
          const res = await chatRequestNonStream(deps, subject, body);
          threadId = res.threadId;
          process.stdout.write(`${res.assistant}\n`);
          const answered = formatAnswered(res.llm, res.model, res.failedOver);
          if (answered !== '') process.stderr.write(`${styleStats(`(${answered})`)}\n`);
        } else {
          threadId = await streamOnce(deps, subject, line, threadId, overrides, bar);
          process.stdout.write('\n');
        }
      } catch (err) {
        process.stderr.write(`error: ${(err as Error).message}\n`);
      }
    }
    rl.close();
  } finally {
    bar?.teardown();
  }
}

interface ThreadControl {
  get(): string | undefined;
  set(id: string | undefined): void;
}

async function handleSlash(
  raw: string,
  deps: ChatCommandDeps,
  subject: ChatSubject,
  overrides: Overrides,
  thread: ThreadControl,
): Promise<'quit' | 'continue'> {
  const [cmd, ...rest] = raw.slice(1).split(/\s+/);
  switch (cmd) {
    case 'quit':
    case 'exit':
      return 'quit';
    case 'clear':
      thread.set(undefined);
      process.stderr.write('(new thread)\n');
      return 'continue';
    case 'threads': {
      try {
        const rows = await deps.client.get<Array<{ id: string; title: string; lastTurnAt: string }>>(
          `/api/v1/${subject.path}/threads`,
        );
        if (rows.length === 0) {
          process.stderr.write('(no saved threads yet)\n');
        } else {
          const active = thread.get();
          for (const r of rows) {
            const mark = r.id === active ? '* ' : '  ';
            const title = r.title !== '' ? ` — ${r.title}` : '';
            process.stderr.write(`${mark}${r.id}  ${new Date(r.lastTurnAt).toLocaleString()}${title}\n`);
          }
          process.stderr.write('(/resume <id> to switch, /delete <id> to remove)\n');
        }
      } catch (err) {
        process.stderr.write(`error listing threads: ${(err as Error).message}\n`);
      }
      return 'continue';
    }
    case 'resume': {
      const id = rest[0];
      if (id === undefined || id === '') {
        process.stderr.write('usage: /resume <thread-id>  (see /threads)\n');
      } else {
        thread.set(id);
        process.stderr.write(`(resuming thread ${id})\n`);
      }
      return 'continue';
    }
    case 'delete': {
      const id = rest[0];
      if (id === undefined || id === '') {
        process.stderr.write('usage: /delete <thread-id>  (see /threads)\n');
        return 'continue';
      }
      if (subject.kind !== 'project') {
        process.stderr.write('(/delete is project-chat only)\n');
        return 'continue';
      }
      try {
        await deps.client.delete(`/api/v1/projects/${encodeURIComponent(subject.name)}/threads/${encodeURIComponent(id)}`);
        process.stderr.write(`(deleted thread ${id})\n`);
        if (thread.get() === id) thread.set(undefined);
      } catch (err) {
        process.stderr.write(`error deleting thread: ${(err as Error).message}\n`);
      }
      return 'continue';
    }
    case 'system': {
      const text = rest.join(' ');
      if (text === '') {
        delete overrides.systemAppend;
        process.stderr.write('(systemAppend cleared)\n');
      } else {
        overrides.systemAppend = text;
        process.stderr.write('(systemAppend set)\n');
      }
      return 'continue';
    }
    case 'set': {
      const [key, ...vparts] = rest;
      if (key === undefined || vparts.length === 0) {
        process.stderr.write('usage: /set KEY VALUE\n');
        return 'continue';
      }
      applySetCommand(overrides, key, vparts.join(' '));
      process.stderr.write(`(${key}=${vparts.join(' ')})\n`);
      return 'continue';
    }
    case 'tools': {
      try {
        let projectName: string | null;
        if (subject.kind === 'project') {
          projectName = subject.name;
        } else {
          const a = await deps.client.get<{ project: { name: string } | null }>(
            `/api/v1/agents/${encodeURIComponent(subject.name)}`,
          );
          projectName = a.project?.name ?? null;
        }
        if (projectName === null) {
          process.stderr.write('(agent has no project — no tools available)\n');
          return 'continue';
        }
        const servers = await deps.client.get<Array<{ server: { name: string } }>>(
          `/api/v1/projects/${encodeURIComponent(projectName)}/servers`,
        );
        if (servers.length === 0) {
          process.stderr.write('(project has no MCP servers attached)\n');
        } else {
          for (const s of servers) {
            process.stderr.write(`  ${s.server.name}\n`);
          }
          if (subject.kind === 'project' && subject.allowSecrets) {
            process.stderr.write('  secrets (get_secret — reads secret values)\n');
          }
        }
      } catch (err) {
        process.stderr.write(`error listing tools: ${(err as Error).message}\n`);
      }
      return 'continue';
    }
    case 'save': {
      if (subject.kind === 'project') {
        process.stderr.write('(/save is agent-only — project chats have no persisted defaults)\n');
        return 'continue';
      }
      try {
        await deps.client.put(`/api/v1/agents/${encodeURIComponent(subject.name)}`, {
          defaultParams: stripSession(overrides),
        });
        process.stderr.write('(saved current overrides as agent.defaultParams)\n');
      } catch (err) {
        process.stderr.write(`error saving: ${(err as Error).message}\n`);
      }
      return 'continue';
    }
    default:
      process.stderr.write(`unknown command: /${cmd ?? ''}\n`);
      return 'continue';
  }
}

function stripSession(o: Overrides): Record<string, unknown> {
  // /save persists sampling defaults but not per-session persona controls
  // (--system / --system-append) or the per-turn --personality overlay.
  // The agent's defaultPersonality is set via PATCH /agents (or `mcpctl edit
  // agent`) — it is NOT a sampling param.
  const out: Record<string, unknown> = { ...o };
  delete out.systemOverride;
  delete out.systemAppend;
  delete out.personality;
  return out;
}

function applySetCommand(o: Overrides, key: string, valueRaw: string): void {
  const num = Number(valueRaw);
  switch (key) {
    case 'temperature': o.temperature = num; break;
    case 'top_p': case 'top-p': o.top_p = num; break;
    case 'top_k': case 'top-k': o.top_k = Math.trunc(num); break;
    case 'max_tokens': case 'max-tokens': o.max_tokens = Math.trunc(num); break;
    case 'seed': o.seed = Math.trunc(num); break;
    case 'stop': o.stop = [valueRaw]; break;
    default:
      // Anything unknown drops into `extra` so the user can still pass it.
      o.extra = { ...(o.extra ?? {}), [key]: parseExtraValue(valueRaw) };
  }
}

/**
 * Non-streaming POST to the chat endpoint. Uses the SAME 10-minute timeout
 * as the streaming path — `deps.client.post` (the shared ApiClient) defaults
 * to 10s, which is too tight for any chat that calls a tool. Returns the
 * parsed JSON body on 2xx, throws on 4xx/5xx with the response body.
 */
async function chatRequestNonStream(
  deps: ChatCommandDeps,
  subject: ChatSubject,
  body: Record<string, unknown>,
): Promise<{ assistant: string; threadId: string; turnIndex: number; llm?: string; model?: string; failedOver?: boolean }> {
  const url = new URL(`${deps.baseUrl}/api/v1/${subject.path}/chat`);
  const payload = JSON.stringify(body);
  return new Promise((resolve, reject) => {
    const driver = url.protocol === 'https:' ? https : http;
    const req = driver.request({
      hostname: url.hostname,
      port: url.port || (url.protocol === 'https:' ? 443 : 80),
      path: url.pathname + url.search,
      method: 'POST',
      timeout: STREAM_TIMEOUT_MS,
      headers: {
        'Content-Type': 'application/json',
        Accept: 'application/json',
        ...(deps.token !== undefined ? { Authorization: `Bearer ${deps.token}` } : {}),
      },
    }, (res) => {
      const status = res.statusCode ?? 0;
      const chunks: Buffer[] = [];
      res.on('data', (c: Buffer) => chunks.push(c));
      res.on('end', () => {
        const raw = Buffer.concat(chunks).toString('utf-8');
        if (status >= 400) {
          reject(new Error(`HTTP ${String(status)}: ${raw}`));
          return;
        }
        try {
          resolve(JSON.parse(raw) as { assistant: string; threadId: string; turnIndex: number; llm?: string; model?: string; failedOver?: boolean });
        } catch (err) {
          reject(new Error(`malformed response: ${(err as Error).message}`));
        }
      });
      res.on('error', reject);
    });
    req.on('error', reject);
    req.on('timeout', () => {
      req.destroy();
      reject(new Error('chat request timed out'));
    });
    req.write(payload);
    req.end();
  });
}

/** Stream a single chat call. Returns the resolved threadId. */
async function streamOnce(
  deps: ChatCommandDeps,
  subject: ChatSubject,
  message: string,
  threadId: string | undefined,
  overrides: Overrides,
  bar: StatusBar | null = null,
): Promise<string> {
  const url = new URL(`${deps.baseUrl}/api/v1/${subject.path}/chat`);
  const body = JSON.stringify(chatBody(subject, message, threadId, overrides, true));

  // Per-response counters. Updated on every text/thinking delta, surfaced
  // live through the bottom-row status bar and the final stats footer.
  const stats = { thinking: newPhase(), content: newPhase() };

  const TICK_MS = 250;
  let timer: NodeJS.Timeout | null = null;
  function startTicker(): void {
    if (timer !== null || bar === null) return;
    timer = setInterval(() => bar.update(formatStats(stats, true)), TICK_MS);
  }
  function stopTicker(): void {
    if (timer !== null) {
      clearInterval(timer);
      timer = null;
    }
  }

  return new Promise<string>((resolve, reject) => {
    const driver = url.protocol === 'https:' ? https : http;
    const req = driver.request({
      hostname: url.hostname,
      port: url.port || (url.protocol === 'https:' ? 443 : 80),
      path: url.pathname + url.search,
      method: 'POST',
      timeout: STREAM_TIMEOUT_MS,
      headers: {
        'Content-Type': 'application/json',
        ...(deps.token !== undefined ? { Authorization: `Bearer ${deps.token}` } : {}),
      },
    }, (res) => {
      const status = res.statusCode ?? 0;
      if (status >= 400) {
        const chunks: Buffer[] = [];
        res.on('data', (c: Buffer) => chunks.push(c));
        res.on('end', () => reject(new Error(`HTTP ${String(status)}: ${Buffer.concat(chunks).toString('utf-8')}`)));
        return;
      }
      let buf = '';
      let resolvedThread = threadId ?? '';
      let answered = '';
      res.setEncoding('utf-8');
      res.on('data', (chunk: string) => {
        buf += chunk;
        let nl: number;
        while ((nl = buf.indexOf('\n\n')) !== -1) {
          const frame = buf.slice(0, nl);
          buf = buf.slice(nl + 2);
          for (const line of frame.split('\n')) {
            if (!line.startsWith('data: ')) continue;
            const data = line.slice(6);
            if (data === '[DONE]') continue;
            try {
              const evt = JSON.parse(data) as ChatStreamFrame;
              switch (evt.type) {
                case 'text':
                  if (typeof evt.delta === 'string') {
                    recordDelta(stats.content, evt.delta);
                    process.stdout.write(evt.delta);
                    startTicker();
                  }
                  break;
                case 'thinking':
                  // Reasoning models (qwen3-thinking, deepseek-reasoner, o1
                  // family) emit this for tens of seconds before producing
                  // any content delta. Show it dim+italic on stderr so the
                  // final answer (stdout) stays clean for grepping/redirect.
                  if (typeof evt.delta === 'string') {
                    recordDelta(stats.thinking, evt.delta);
                    process.stderr.write(styleThinking(evt.delta));
                    startTicker();
                  }
                  break;
                case 'tool_call':
                  process.stderr.write(`\n[tool_call: ${evt.toolName ?? ''}]\n`);
                  break;
                case 'tool_result':
                  process.stderr.write(`[tool_result: ${evt.toolName ?? ''} ${evt.ok === false ? 'FAIL' : 'ok'}]\n`);
                  break;
                case 'final':
                  if (evt.threadId !== undefined) resolvedThread = evt.threadId;
                  answered = formatAnswered(evt.llm, evt.model, evt.failedOver);
                  break;
                case 'error':
                  process.stderr.write(`\n[error: ${evt.message ?? ''}]\n`);
                  break;
              }
            } catch {
              // ignore malformed frames
            }
          }
        }
      });
      res.on('end', () => {
        stopTicker();
        const final = formatStats(stats, false);
        // Inline final-stats footer (permanent record): goes through the
        // scroll region, so a copy-pasted transcript captures it.
        const footer = [final, answered].filter((s) => s !== '').join('  ·  ');
        if (footer !== '' && STDERR_IS_TTY) {
          process.stderr.write(`\n${styleStats(`(${footer})`)}`);
        } else if (footer !== '') {
          process.stderr.write(`\n(${footer})`);
        }
        // Live status bar: pin the final value so it stays visible between
        // turns (answers "how fast was the last one?").
        if (bar !== null && final !== '') bar.update(final);
        resolve(resolvedThread);
      });
      res.on('error', (err) => { stopTicker(); reject(err); });
    });
    req.on('error', (err) => { stopTicker(); reject(err); });
    req.on('timeout', () => {
      stopTicker();
      req.destroy();
      reject(new Error('chat stream timed out'));
    });
    req.write(body);
    req.end();
  });
}

interface ChatStreamFrame {
  type: 'text' | 'thinking' | 'tool_call' | 'tool_result' | 'final' | 'error';
  delta?: string;
  toolName?: string;
  ok?: boolean;
  threadId?: string;
  turnIndex?: number;
  message?: string;
  llm?: string;
  model?: string;
  failedOver?: boolean;
}

/** One-line "which model answered" footer, dim; loud when it failed over. */
function formatAnswered(llm?: string, model?: string, failedOver?: boolean): string {
  if (llm === undefined && model === undefined) return '';
  const label = model !== undefined && model !== '' ? `${llm ?? '?'} (${model})` : (llm ?? '?');
  return failedOver === true ? `⚠ failed over → answered by ${label}` : `model: ${label}`;
}

// ANSI codes for the reasoning sidebar. Dim + italic visually separates
// reasoning ("the model is thinking") from final assistant content. We only
// emit the codes when stderr is a TTY — piping to a file should stay clean.
export const ANSI_DIM_ITALIC = '\x1b[2;3m';
export const ANSI_DIM = '\x1b[2m';
export const ANSI_RESET = '\x1b[0m';
export const STDERR_IS_TTY = process.stderr.isTTY === true;
export function styleThinking(s: string): string {
  return STDERR_IS_TTY ? `${ANSI_DIM_ITALIC}${s}${ANSI_RESET}` : s;
}
export function styleStats(s: string): string {
  return STDERR_IS_TTY ? `${ANSI_DIM}${s}${ANSI_RESET}` : s;
}

export interface PhaseStats {
  words: number;
  firstMs: number;
  lastMs: number;
}
export function newPhase(): PhaseStats { return { words: 0, firstMs: 0, lastMs: 0 }; }
export function recordDelta(p: PhaseStats, delta: string): void {
  const now = Date.now();
  if (p.firstMs === 0) p.firstMs = now;
  p.lastMs = now;
  // Whitespace-separated tokens. Good enough across languages without a
  // tokenizer dependency. CJK languages will under-count, but for English/
  // code/Markdown (the common case) this matches user expectations.
  const matches = delta.match(/\S+/g);
  if (matches !== null) p.words += matches.length;
}
function formatPhase(label: string, p: PhaseStats): string | null {
  if (p.words === 0) return null;
  const sec = Math.max(0.05, (p.lastMs - p.firstMs) / 1000);
  const rate = p.words / sec;
  return `${label}${String(p.words)}w · ${rate.toFixed(1)} w/s · ${sec.toFixed(1)}s`;
}
export function formatStats(s: { thinking: PhaseStats; content: PhaseStats }, partial: boolean): string {
  const parts: string[] = [];
  const c = formatPhase('', s.content);
  if (c !== null) parts.push(c);
  const t = formatPhase('thinking ', s.thinking);
  if (t !== null) parts.push(t);
  if (parts.length === 0) return '';
  const prefix = partial ? '⏵ ' : '';
  return `${prefix}${parts.join(' | ')}`;
}

/**
 * Bottom-row status bar via DECSTBM (terminal scroll region). The top of the
 * terminal scrolls for content, the last row is locked and redrawn in place.
 *
 * Why this and not cursor save+restore: save+restore (`\x1b[s` / `\x1b[u`)
 * is unreliable when content scrolls or wraps — the saved row drifts off the
 * visible area and the restore lands inside content lines, smearing the
 * status text into mid-word positions. DECSTBM gives us a region that
 * scrolls independently of the locked status row, so streaming content can
 * never overlap the live counter.
 *
 * Returns null when stdout isn't a TTY or the terminal is too small. Pipes
 * (`mcpctl chat reviewer | tee log`) get plain text — no escape codes leak.
 *
 * Idempotent: install() once per chat-session lifecycle; teardown() can be
 * called multiple times. Process-level signal handlers ensure we don't leave
 * a foreign terminal in a half-locked state if Ctrl-C / uncaught exception
 * fires mid-stream.
 */
export interface StatusBar {
  update(text: string): void;
  teardown(): void;
}

export function installStatusBar(): StatusBar | null {
  const out = process.stdout;
  if (!out.isTTY) return null;
  const initialRows = out.rows;
  if (typeof initialRows !== 'number' || initialRows < 5) return null;

  let active = true;
  let lastText = '';
  let currentRows = initialRows;

  function setScrollRegion(rows: number): void {
    // \x1b[<top>;<bot>r — set scroll region (1-indexed, inclusive). Reserve
    // last row for status. Position cursor at top of the scrollable area.
    out.write(`\x1b[1;${String(rows - 1)}r\x1b[${String(rows - 1)};1H`);
  }

  function drawAt(rows: number, text: string): void {
    if (text === '') return;
    // \x1b 7 / \x1b 8 — DEC save/restore cursor (more portable across
    // terminals than xterm's \x1b[s / \x1b[u). We move to the bottom row,
    // clear it, write the dim status, restore cursor inside the scroll
    // region where content/prompt naturally lives.
    out.write(`\x1b7\x1b[${String(rows)};1H\x1b[K${styleStats(text)}\x1b8`);
  }

  setScrollRegion(currentRows);

  function update(text: string): void {
    if (!active) return;
    lastText = text;
    drawAt(currentRows, text);
  }

  function onResize(): void {
    if (!active) return;
    const rows = out.rows;
    if (typeof rows !== 'number' || rows < 5) return;
    currentRows = rows;
    setScrollRegion(currentRows);
    drawAt(currentRows, lastText);
  }

  function teardown(): void {
    if (!active) return;
    active = false;
    out.removeListener('resize', onResize);
    process.removeListener('exit', teardown);
    process.removeListener('SIGINT', sigintHandler);
    process.removeListener('SIGTERM', sigintHandler);
    process.removeListener('uncaughtException', uncaughtHandler);
    // Clear the status row, reset scroll region to full terminal, leave
    // cursor at start of the (former) status row so the user's next shell
    // prompt has a clean line.
    out.write(`\x1b[${String(currentRows)};1H\x1b[K\x1b[r`);
  }

  function sigintHandler(): void {
    teardown();
    // Re-raise: process.exit with conventional 130 (Ctrl-C exit code).
    process.exit(130);
  }

  function uncaughtHandler(err: unknown): void {
    teardown();
    // Print the original error to the now-restored terminal.
    process.stderr.write(`\n${err instanceof Error ? err.stack ?? err.message : String(err)}\n`);
    process.exit(1);
  }

  out.on('resize', onResize);
  process.on('exit', teardown);
  process.on('SIGINT', sigintHandler);
  process.on('SIGTERM', sigintHandler);
  process.on('uncaughtException', uncaughtHandler);

  return { update, teardown };
}

interface AgentInfo {
  name: string;
  description: string;
  systemPrompt: string;
  llm: { name: string };
  project: { name: string } | null;
  defaultPersonality?: { name: string } | null;
}

interface ProjectInfo {
  name: string;
  description: string;
  prompt?: string;
  llmProvider?: string | null;
  llmModel?: string | null;
}

/**
 * Prints a startup banner showing what the chat session will be running with:
 * agent name, LLM, project, the assembled system prompt, and any session
 * overrides. Lets the user verify the wiring before they spend tokens.
 *
 * Best-effort: if the agent fetch fails we log and continue rather than
 * blocking the chat — the user might still want to send a message.
 */
async function printChatHeader(
  deps: ChatCommandDeps,
  subject: ChatSubject,
  overrides: Overrides,
): Promise<void> {
  const sep = '─'.repeat(60);
  const out = (s: string): void => { process.stderr.write(`${styleStats(s)}\n`); };
  const indent = (text: string): string =>
    text.split('\n').map((l) => `  ${l}`).join('\n');

  if (subject.kind === 'project') {
    let proj: ProjectInfo;
    try {
      proj = await deps.client.get<ProjectInfo>(`/api/v1/projects/${encodeURIComponent(subject.name)}`);
    } catch (err) {
      process.stderr.write(`(could not fetch project metadata: ${(err as Error).message})\n`);
      return;
    }
    out(sep);
    out(`Project: ${proj.name}${proj.description !== '' ? ` — ${proj.description}` : ''}`);
    const model = proj.llmModel !== null && proj.llmModel !== undefined && proj.llmModel !== '' ? ` (${proj.llmModel})` : '';
    out(`LLM: ${proj.llmProvider ?? '(none set — set project.llmProvider)'}${model}`);
    if (overrides.systemOverride !== undefined) {
      out('System prompt (--system replaces project.prompt):');
      out(indent(overrides.systemOverride));
    } else if (proj.prompt !== undefined && proj.prompt !== '') {
      out('Project prompt:');
      out(indent(proj.prompt));
    }
    if (overrides.systemAppend !== undefined) {
      out('System append (--system-append):');
      out(indent(overrides.systemAppend));
    }
    out('(project Prompts auto-appended at chat time; /tools lists MCP servers)');
    if (subject.allowSecrets) out('Secret access: ENABLED (get_secret tool available — requires view:secrets)');
    const so = describeSessionOverrides(overrides);
    if (so !== '') out(`Sampling overrides: ${so}`);
    out(sep);
    return;
  }

  let info: AgentInfo;
  try {
    info = await deps.client.get<AgentInfo>(`/api/v1/agents/${encodeURIComponent(subject.name)}`);
  } catch (err) {
    process.stderr.write(`(could not fetch agent metadata: ${(err as Error).message})\n`);
    return;
  }

  out(sep);
  out(`Agent: ${info.name}${info.description !== '' ? ` — ${info.description}` : ''}`);
  const tail = info.project !== null ? `   Project: ${info.project.name}` : '';
  out(`LLM: ${info.llm.name}${tail}`);

  // Personality overlay: explicit --personality wins; otherwise agent's
  // defaultPersonality (if set). Tells the user which prompt bundle is
  // active before they type anything.
  if (overrides.personality !== undefined) {
    out(`Personality: ${overrides.personality} (--personality)`);
  } else if (info.defaultPersonality) {
    out(`Personality: ${info.defaultPersonality.name} (agent default)`);
  }

  if (overrides.systemOverride !== undefined) {
    out(`System prompt (--system replaces agent.systemPrompt):`);
    out(indent(overrides.systemOverride));
  } else {
    out(`System prompt:`);
    out(indent(info.systemPrompt !== '' ? info.systemPrompt : '(empty)'));
  }
  if (overrides.systemAppend !== undefined) {
    out(`System append (--system-append):`);
    out(indent(overrides.systemAppend));
  }
  if (info.project !== null) {
    out(`(project prompts auto-appended at chat time; /tools lists MCP servers)`);
  }

  const sessionOverrides = describeSessionOverrides(overrides);
  if (sessionOverrides !== '') {
    out(`Sampling overrides: ${sessionOverrides}`);
  }
  out(sep);
}

function describeSessionOverrides(o: Overrides): string {
  const parts: string[] = [];
  if (o.temperature !== undefined) parts.push(`temperature=${String(o.temperature)}`);
  if (o.top_p !== undefined) parts.push(`top_p=${String(o.top_p)}`);
  if (o.top_k !== undefined) parts.push(`top_k=${String(o.top_k)}`);
  if (o.max_tokens !== undefined) parts.push(`max_tokens=${String(o.max_tokens)}`);
  if (o.seed !== undefined) parts.push(`seed=${String(o.seed)}`);
  if (o.stop !== undefined && o.stop.length > 0) parts.push(`stop=${o.stop.join(',')}`);
  if (o.tools_allowlist !== undefined) parts.push(`allow_tools=${o.tools_allowlist.join(',')}`);
  if (o.extra !== undefined) {
    for (const [k, v] of Object.entries(o.extra)) parts.push(`${k}=${String(v)}`);
  }
  return parts.join(' ');
}

function collect(value: string, prev: string[]): string[] {
  return [...prev, value];
}

function parseFloatInt(value: string): number {
  const n = Number(value);
  if (!Number.isInteger(n)) throw new Error(`expected integer, got '${value}'`);
  return n;
}
