import * as readline from 'node:readline';
import type { ManagedSession } from '../../session/index.js';
import type { ModelClient, TokenUsage } from '../../models/types.js';
import type { ModelRouter } from '../../models/router.js';
import type { NativeAgent, ToolUseEvent } from '../../backends/native/agent.js';
import { parseCommand, getHelpText, resolveModelAlias, getCommandCompletions, getCommandTooltip, isToolInventoryQuery, type Command } from './commands.js';
import { renderMarkdown } from './markdown.js';
import type { ModelConfig, ModelProvider } from '../../config/schema.js';
import { MODEL_PROVIDERS } from '../../config/schema.js';
import { OllamaClient, LlamaCppClient } from '../../models/index.js';
import { createClientFromConfig } from '../../daemon/index.js';
import {
  loadStoredAnthropicAuth,
  loadStoredAnthropicAuthToken,
  loadStoredOpenAIApiKey,
  loadStoredOpenAIAuth,
  loadStoredZaiAuth,
  loginGitHub,
  loginOpenAI,
  storeAnthropicAuth,
  storeAnthropicAuthToken,
  storeOpenAIApiKey,
  storeZaiAuth,
} from '../../auth/index.js';
import type { PairingManager } from '../../channels/pairing.js';
import { getColoredBanner } from './banner.js';
import type { HookEngine } from '../../hooks/index.js';
import { estimateMessageTokens, getContextWindow } from '../../context/tokens.js';
import { getElevationStatusMessage, setElevationFromInput } from '../../security/elevation.js';

export { parseCommand, type Command };

// ANSI color codes
const colors = {
  reset: '\x1b[0m',
  bold: '\x1b[1m',
  dim: '\x1b[2m',
  blue: '\x1b[34m',
  orange: '\x1b[38;5;208m',
  gray: '\x1b[90m',
  bgDark: '\x1b[48;5;234m',
};

export function formatPrompt(state: 'default' | 'thinking'): string {
  if (state === 'thinking') {
    return `${colors.orange}flynn...${colors.reset} `;
  }
  return `${colors.orange}${colors.bold}flynn>${colors.reset} `;
}

function formatMessageTimestampParts(timestamp: number): { date: string; time: string } {
  const date = new Date(timestamp);
  return {
    date: date.toLocaleDateString([], { month: 'short', day: 'numeric', year: 'numeric' }),
    time: date.toLocaleTimeString([], {
      hour: '2-digit',
      minute: '2-digit',
      second: '2-digit',
    }),
  };
}

export interface MinimalTuiConfig {
  session: ManagedSession;
  modelClient: ModelClient;
  modelRouter?: ModelRouter;
  systemPrompt: string;
  agent?: NativeAgent;
  onFullscreen?: () => void;
  onTransfer?: (target: string) => string | void;
  onTools?: () => string;
  onResearch?: (task: string) => Promise<string> | string;
  onCouncil?: (task: string) => Promise<string> | string;
  onRuntimeCommand?: (input?: string) => Promise<string> | string;
  localProviders?: Record<string, ModelConfig>;
  modelProviderConfigs?: Partial<Record<ModelProvider, ModelConfig>>;
  currentLocalProvider?: string;
  pairingManager?: PairingManager;
  hookEngine?: HookEngine;
  contextThresholdPct?: number;
}

export class MinimalTui {
  private static readonly CTRL_C_EXIT_WINDOW_MS = 1_500;
  private static readonly BUSY_FRAMES = ['-', '\\', '|', '/'] as const;
  private rl: readline.Interface | null = null;
  private running = false;
  private totalUsage: TokenUsage = { inputTokens: 0, outputTokens: 0 };
  private currentHint = '';
  private lastLine = '';
  private activePromptCancel: (() => void) | null = null;
  private activeOperationCancel: (() => void) | null = null;
  private keypressHandler: ((char: string, key: readline.Key) => void) | null = null;
  private busyTimer: NodeJS.Timeout | null = null;
  private busyFrameIndex = 0;
  private busyActive = false;
  private verbose = false;
  private lastCtrlCAtMs = 0;
  private commandInFlight = false;

  constructor(private config: MinimalTuiConfig) {}

  private isEscapeKey(char: string, key: readline.Key): boolean {
    return key?.name === 'escape' || key?.sequence === '\x1b' || char === '\x1b';
  }

  private handleEscapeAction(): boolean {
    if (this.activePromptCancel) {
      this.activePromptCancel();
      return true;
    }

    if (this.activeOperationCancel) {
      this.activeOperationCancel();
      return true;
    }

    if (this.rl) {
      try {
        this.rl.write(null, { ctrl: true, name: 'u' });
      } catch {
        // ignore
      }
      return true;
    }

    return false;
  }

  private showHint(line: string): void {
    if (!line.startsWith('/')) {
      this.clearHint();
      return;
    }

    const completions = getCommandCompletions(line);
    const tooltip = getCommandTooltip(line);

    let hint = '';

    if (completions.length === 1 && completions[0] !== line) {
      // Show the remaining part of the completion as a hint
      hint = completions[0].slice(line.length);
    }

    // Add tooltip if available
    if (tooltip) {
      hint += `  ${colors.gray}— ${tooltip}${colors.reset}`;
    } else if (completions.length > 1) {
      hint += `  ${colors.gray}[${completions.length} options, Tab to complete]${colors.reset}`;
    }

    if (hint && hint !== this.currentHint) {
      this.clearHint();
      this.currentHint = hint;
      // Save cursor, write dim hint, restore cursor
      process.stdout.write(`\x1b[s${colors.dim}${hint}${colors.reset}\x1b[u`);
    } else if (!hint) {
      this.clearHint();
    }
  }

  private clearHint(): void {
    if (this.currentHint) {
      // Clear from cursor to end of line
      process.stdout.write('\x1b[K');
      this.currentHint = '';
    }
  }

  private formatToolName(name: string): string {
    const parts = name.split('.');
    return parts.map((p, i) => {
      const capitalized = p.charAt(0).toUpperCase() + p.slice(1);
      return i === 0 && parts.length > 1 ? capitalized + ':' : capitalized;
    }).join(' ');
  }

  private formatToolArgs(args: unknown): string {
    if (!args || typeof args !== 'object') {
      return '';
    }
    const entries = Object.entries(args as Record<string, unknown>);
    if (entries.length === 0) {
      return '';
    }
    return entries.map(([key, value]) => {
      if (typeof value === 'string') {
        const display = value.length > 50 ? `${value.slice(0, 47)}...` : value;
        return `${key}: "${display}"`;
      }
      if (typeof value === 'number' || typeof value === 'boolean') {
        return `${key}: ${value}`;
      }
      return `${key}: ${JSON.stringify(value)}`;
    }).join(', ');
  }

  private startBusyIndicator(): void {
    if (this.busyActive) {
      return;
    }

    this.busyActive = true;
    const render = () => {
      const frame = MinimalTui.BUSY_FRAMES[this.busyFrameIndex];
      const message = `${formatPrompt('thinking')}${colors.gray}${frame} Flynn is thinking... (Esc to cancel)${colors.reset}`;
      if (process.stdout.isTTY) {
        process.stdout.write(`\r${message}`);
      } else {
        console.log(message);
      }
    };

    render();
    if (!process.stdout.isTTY) {
      return;
    }

    this.busyTimer = setInterval(() => {
      this.busyFrameIndex = (this.busyFrameIndex + 1) % MinimalTui.BUSY_FRAMES.length;
      render();
    }, 125);
  }

  private stopBusyIndicator(): void {
    if (!this.busyActive) {
      return;
    }
    this.busyActive = false;
    this.busyFrameIndex = 0;
    if (this.busyTimer) {
      clearInterval(this.busyTimer);
      this.busyTimer = null;
    }
    if (process.stdout.isTTY) {
      process.stdout.write('\r\x1b[K');
    }
  }

  private clearSubmittedPromptLine(): boolean {
    if (!process.stdout.isTTY) {
      return false;
    }

    try {
      readline.moveCursor(process.stdout, 0, -1);
      readline.clearLine(process.stdout, 0);
      readline.cursorTo(process.stdout, 0);
      return true;
    } catch {
      return false;
    }
  }

  private handleToolEvent(event: ToolUseEvent): void {
    if (!this.verbose) {
      return;
    }
    this.stopBusyIndicator();
    if (event.type === 'start') {
      const label = this.formatToolName(event.tool);
      const argsStr = event.args ? ` (${this.formatToolArgs(event.args)})` : '';
      console.log(`${colors.gray}> ${label}${argsStr}${colors.reset}`);
      return;
    }
    if (event.type === 'end' && event.result) {
      if (event.result.success) {
        console.log(`${colors.gray}  done (${event.result.output.split('\n').length} lines)${colors.reset}`);
      } else {
        console.log(`${colors.gray}  error ${event.result.error ?? 'unknown error'}${colors.reset}`);
      }
    }
  }

  async start(): Promise<void> {
    this.running = true;

    if (this.config.agent && this.config.modelRouter) {
      this.config.agent.setModelTier(this.config.modelRouter.getTier());
    }
    if (this.config.agent) {
      this.config.agent.setOnToolUse(this.handleToolEvent.bind(this));
    }

    this.rl = readline.createInterface({
      input: process.stdin,
      output: process.stdout,
      completer: (line: string) => {
        const completions = getCommandCompletions(line);
        return [completions, line];
      },
    });

    this.rl.on('SIGINT', () => {
      if (this.handleCtrlCPress()) {
        this.stop();
      }
    });

    // Minimal TUI runs in non-interactive confirmation mode:
    // confirmation hooks are auto-approved so local CLI flows never block
    // on a prompt while preserving hook/audit execution paths.
    if (this.config.hookEngine) {
      this.config.hookEngine.setInteractiveConfirmer(async () => ({ approved: true }));
    }

    // Listen for line changes to show hints
    this.keypressHandler = (char: string, key: readline.Key) => {
      if (this.isEscapeKey(char, key)) {
        this.handleEscapeAction();
        return;
      }

      // Small delay to let readline update the line
      setImmediate(() => {
        if (this.rl) {
          const line = (this.rl as readline.Interface & { line?: string }).line || '';
          if (line !== this.lastLine) {
            this.lastLine = line;
            this.showHint(line);
          }
        }
      });
    };
    process.stdin.on('keypress', this.keypressHandler);

    // Enable keypress events
    if (process.stdin.isTTY) {
      readline.emitKeypressEvents(process.stdin);
    }

    console.log(getColoredBanner());
    console.log(`\n${colors.orange}${colors.bold}Flynn TUI${colors.reset} ${colors.dim}(minimal mode)${colors.reset}`);
    console.log(`${colors.gray}Type /help for commands, /fullscreen for panel mode, Esc to cancel prompts/runs, Ctrl+C to clear input (press twice to quit)${colors.reset}\n`);

    await this.promptLoop();
  }

  handleCtrlCPress(nowMs = Date.now()): boolean {
    if (!this.rl) {
      return true;
    }

    // If a command is currently running (e.g. /council), exit immediately.
    // Some command paths are not cancellable mid-flight, so double-press UX can trap users.
    if (this.commandInFlight) {
      this.activeOperationCancel?.();
      return true;
    }

    const shouldExit = this.lastCtrlCAtMs > 0
      && (nowMs - this.lastCtrlCAtMs) <= MinimalTui.CTRL_C_EXIT_WINDOW_MS;
    this.lastCtrlCAtMs = nowMs;

    if (shouldExit) {
      return true;
    }

    try {
      this.rl.write(null, { ctrl: true, name: 'u' });
    } catch {
      // ignore
    }
    console.log(`${colors.gray}Press Ctrl+C again to quit (or use /quit).${colors.reset}`);
    if (this.running) {
      this.rl.prompt();
    }
    return false;
  }

  private async promptLoop(): Promise<void> {
    while (this.running && this.rl) {
      this.lastCtrlCAtMs = 0;
      this.lastLine = '';
      this.currentHint = '';
      const input = await this.prompt(formatPrompt('default'));
      this.clearHint();
      const command = parseCommand(input);

      if (!command) {
        continue;
      }

      this.commandInFlight = true;
      try {
        await this.handleCommand(command);
      } finally {
        this.commandInFlight = false;
      }
    }
  }

  private prompt(promptText: string): Promise<string> {
    return new Promise((resolve) => {
      if (!this.rl) {
        resolve('');
        return;
      }

      let settled = false;
      const stdin = process.stdin as NodeJS.ReadStream & {
        isRaw?: boolean;
        setRawMode?: (mode: boolean) => void;
      };
      const wasRaw = Boolean(stdin.isRaw);
      let enabledRawForPrompt = false;
      let dataListener: ((chunk: Buffer) => void) | null = null;

      if (stdin.isTTY && stdin.setRawMode && !wasRaw) {
        stdin.setRawMode(true);
        enabledRawForPrompt = true;
      }

      const finish = (answer: string) => {
        if (settled) {
          return;
        }
        settled = true;
        if (dataListener) {
          stdin.removeListener('data', dataListener);
          dataListener = null;
        }
        if (enabledRawForPrompt && stdin.setRawMode) {
          stdin.setRawMode(false);
        }
        this.activePromptCancel = null;
        this.rl?.removeListener('close', onClose);
        resolve(answer);
      };

      const onClose = () => finish('');
      this.rl.once('close', onClose);
      dataListener = (chunk: Buffer) => {
        if (chunk.length === 1 && chunk[0] === 0x1b) {
          this.activePromptCancel?.();
        }
      };
      stdin.on('data', dataListener);
      this.activePromptCancel = () => {
        if (!this.rl) {
          finish('');
          return;
        }
        try {
          this.rl.write(null, { ctrl: true, name: 'u' });
          this.rl.write(null, { name: 'return' });
        } catch {
          finish('');
        }
      };

      try {
        this.rl.question(promptText, (answer) => {
          finish(answer);
        });
      } catch {
        // readline can throw synchronously if it was closed between our guards.
        finish('');
      }
    });
  }

  private async handleCommand(command: Command): Promise<void> {
    switch (command.type) {
      case 'quit':
        this.stop();
        break;

      case 'reset':
        // Always clear the local session transcript, even in agent-backed mode,
        // so the UI view matches the reset agent context.
        this.config.session.clear();
        this.config.agent?.reset();

        this.totalUsage = { inputTokens: 0, outputTokens: 0 };
        console.log(`${colors.gray}Session cleared.${colors.reset}\n`);
        break;

      case 'help':
        console.log(getHelpText() + '\n');
        break;

      case 'multiline':
        await this.handleMultilineCommand();
        break;

      case 'status':
        this.printStatus();
        break;

      case 'tools':
        this.handleToolsCommand();
        break;

      case 'research':
        await this.handleResearchCommand(command.task);
        break;

      case 'council':
        await this.handleCouncilCommand(command.task);
        break;

      case 'fullscreen':
        this.config.onFullscreen?.();
        break;

      case 'compact':
        await this.handleCompactCommand();
        break;

      case 'usage':
        this.handleUsageCommand();
        break;

      case 'context':
        this.handleContextCommand();
        break;

      case 'verbose':
        this.handleVerboseCommand();
        break;

      case 'model':
        this.handleModelCommand(command.name, command.providerModel);
        break;

      case 'backend':
        await this.handleBackendCommand(command.provider);
        break;

      case 'runtime':
        await this.handleRuntimeCommand(command.input);
        break;

      case 'login':
        await this.handleLoginCommand(command.provider);
        break;

      case 'pair':
        this.handlePairCommand(command.action, command.args);
        break;

      case 'queue':
        this.handleQueueCommand(command.action, command.args);
        break;

      case 'elevate':
        this.handleElevateCommand(command.args);
        break;

      case 'transfer':
      {
        if (!this.config.onTransfer) {
          console.log(`${colors.gray}Transfer target is not available in this TUI mode.${colors.reset}\n`);
          break;
        }
        const result = this.config.onTransfer(command.target);
        if (typeof result === 'string' && result.trim()) {
          console.log(`${result}\n`);
        }
        break;
      }

      case 'message':
        await this.handleMessage(command.content);
        break;

      default: {
        const exhaustive: never = command;
        throw new Error(`Unhandled command: ${JSON.stringify(exhaustive)}`);
      }
    }
  }

  private async handleCompactCommand(): Promise<void> {
    console.log(`${colors.gray}Compact command is not available in this TUI mode.${colors.reset}\n`);
  }

  private handleUsageCommand(): void {
    this.printStatus();
  }

  private handleToolsCommand(): void {
    if (!this.config.onTools) {
      console.log(`${colors.gray}Tools command is not available in this TUI mode.${colors.reset}\n`);
      return;
    }
    const output = this.config.onTools();
    console.log(`${output}\n`);
  }

  private async handleResearchCommand(task: string): Promise<void> {
    if (!task.trim()) {
      console.log(`${colors.gray}Usage: /research <question or task>${colors.reset}\n`);
      return;
    }
    if (!this.config.onResearch) {
      console.log(`${colors.gray}Research command is not available in this TUI mode.${colors.reset}\n`);
      return;
    }
    const output = await this.config.onResearch(task);
    console.log(`${output}\n`);
  }

  private async handleCouncilCommand(task: string): Promise<void> {
    if (!task.trim()) {
      console.log(`${colors.gray}Usage: /council <question or task> | /council preflight${colors.reset}\n`);
      return;
    }
    if (!this.config.onCouncil) {
      console.log(`${colors.gray}Council command is not available in this TUI mode.${colors.reset}\n`);
      return;
    }
    const output = await this.config.onCouncil(task);
    console.log(`${output}\n`);
  }

  private async handleMultilineCommand(): Promise<void> {
    console.log(`${colors.gray}Multiline mode: paste/type content. End with a single "." on its own line.${colors.reset}`);
    const lines: string[] = [];

    while (this.running && this.rl) {
      const line = await this.prompt(`${colors.orange}...${colors.reset} `);
      if (line === '.') {
        break;
      }
      lines.push(line);
    }

    const content = lines.join('\n').trim();
    if (!content) {
      console.log(`${colors.gray}Multiline input cancelled.${colors.reset}\n`);
      return;
    }

    await this.handleMessage(content);
  }

  private handleContextCommand(): void {
    const history = this.config.session.getHistory();
    const estimated = estimateMessageTokens(history);
    const tier = this.config.modelRouter?.getTier() ?? 'default';
    const modelName = this.config.modelRouter?.getLabel(tier) ?? 'unknown';
    const window = getContextWindow(modelName);
    const usagePct = window > 0 ? (estimated / window) * 100 : 0;
    const thresholdPct = this.config.contextThresholdPct ?? 80;
    const thresholdTokens = Math.floor((thresholdPct / 100) * window);
    const remaining = Math.max(0, window - estimated);

    console.log(`${colors.gray}Context usage (estimated):${colors.reset}`);
    console.log(`  model: ${modelName}`);
    console.log(`  used: ${estimated.toLocaleString()} / ${window.toLocaleString()} tokens (${usagePct.toFixed(1)}%)`);
    console.log(`  remaining: ${remaining.toLocaleString()} tokens`);
    console.log(`  compaction threshold: ${thresholdPct}% (${thresholdTokens.toLocaleString()} tokens)`);
    console.log(`  should compact: ${estimated > thresholdTokens ? 'yes' : 'no'}\n`);
  }

  private handleVerboseCommand(): void {
    this.verbose = !this.verbose;
    console.log(`${colors.gray}Verbose mode:${colors.reset} ${this.verbose ? 'on' : 'off'}`);
    if (this.verbose && this.config.agent) {
      const snapshot = this.config.agent.getToolInventorySnapshot();
      console.log(
        `[Agent] tool-inventory session=${snapshot.sessionId} agent=${snapshot.agent} provider=${snapshot.provider} skill=${snapshot.skill} internal=${snapshot.internalCount} exposed=${snapshot.exposedCount} internal_browser=[${snapshot.internalBrowser.join(', ') || 'none'}] exposed_browser=[${snapshot.exposedBrowser.join(', ') || 'none'}]`,
      );
    }
    console.log('');
  }

  private handleQueueCommand(action?: 'show' | 'set' | 'reset', args?: string): void {
    if (!action || action === 'show') {
      const mode = this.config.session.getConfig('queue.mode') ?? 'collect';
      const cap = this.config.session.getConfig('queue.cap') ?? '50';
      const overflow = this.config.session.getConfig('queue.overflow') ?? 'drop_old';
      const debounceMs = this.config.session.getConfig('queue.debounce_ms') ?? '0';
      const summarizeOverflow = this.config.session.getConfig('queue.summarize_overflow') ?? 'true';
      const hasSessionOverride = Boolean(
        this.config.session.getConfig('queue.mode')
          || this.config.session.getConfig('queue.cap')
          || this.config.session.getConfig('queue.overflow')
          || this.config.session.getConfig('queue.debounce_ms')
          || this.config.session.getConfig('queue.summarize_overflow'),
      );
      console.log(`${colors.gray}Queue policy:${colors.reset}`);
      console.log(`  mode: ${mode}`);
      console.log(`  cap: ${cap}`);
      console.log(`  overflow: ${overflow}`);
      console.log(`  debounce_ms: ${debounceMs}`);
      console.log(`  summarize_overflow: ${summarizeOverflow}`);
      console.log(`  source: ${hasSessionOverride ? 'session override' : 'defaults'}\n`);
      return;
    }

    if (action === 'reset') {
      this.config.session.deleteConfig('queue.mode');
      this.config.session.deleteConfig('queue.cap');
      this.config.session.deleteConfig('queue.overflow');
      this.config.session.deleteConfig('queue.debounce_ms');
      this.config.session.deleteConfig('queue.summarize_overflow');
      console.log(`${colors.gray}Reset session queue overrides.${colors.reset}\n`);
      return;
    }

    const raw = (args ?? '').trim();
    const [rawKey, ...rest] = raw.split(/\s+/);
    const value = rest.join(' ').trim();
    if (!rawKey || !value) {
      console.log(`${colors.gray}Usage: /queue set <mode|cap|overflow|debounce_ms|summarize_overflow> <value>${colors.reset}\n`);
      return;
    }

    const key = rawKey.toLowerCase();
    if (key === 'mode') {
      if (!['collect', 'followup', 'steer', 'steer_backlog', 'interrupt'].includes(value)) {
        console.log(`${colors.gray}Invalid mode. Use: collect, followup, steer, steer_backlog, interrupt${colors.reset}\n`);
        return;
      }
      this.config.session.setConfig('queue.mode', value);
      console.log(`${colors.gray}Set queue.mode=${value}.${colors.reset}\n`);
      return;
    }

    if (key === 'cap') {
      const cap = Number.parseInt(value, 10);
      if (!Number.isFinite(cap) || cap < 1 || cap > 1000) {
        console.log(`${colors.gray}Invalid cap. Use an integer between 1 and 1000.${colors.reset}\n`);
        return;
      }
      this.config.session.setConfig('queue.cap', String(cap));
      console.log(`${colors.gray}Set queue.cap=${cap}.${colors.reset}\n`);
      return;
    }

    if (key === 'overflow') {
      if (value !== 'drop_old' && value !== 'drop_new') {
        console.log(`${colors.gray}Invalid overflow. Use drop_old or drop_new.${colors.reset}\n`);
        return;
      }
      this.config.session.setConfig('queue.overflow', value);
      console.log(`${colors.gray}Set queue.overflow=${value}.${colors.reset}\n`);
      return;
    }

    if (key === 'debounce_ms') {
      const debounceMs = Number.parseInt(value, 10);
      if (!Number.isFinite(debounceMs) || debounceMs < 0 || debounceMs > 60_000) {
        console.log(`${colors.gray}Invalid debounce_ms. Use an integer between 0 and 60000.${colors.reset}\n`);
        return;
      }
      this.config.session.setConfig('queue.debounce_ms', String(debounceMs));
      console.log(`${colors.gray}Set queue.debounce_ms=${debounceMs}.${colors.reset}\n`);
      return;
    }

    if (key === 'summarize_overflow') {
      const normalized = value.toLowerCase();
      if (normalized !== 'true' && normalized !== 'false') {
        console.log(`${colors.gray}Invalid summarize_overflow. Use true or false.${colors.reset}\n`);
        return;
      }
      this.config.session.setConfig('queue.summarize_overflow', normalized);
      console.log(`${colors.gray}Set queue.summarize_overflow=${normalized}.${colors.reset}\n`);
      return;
    }

    console.log(`${colors.gray}Unknown queue key. Use mode, cap, overflow, debounce_ms, summarize_overflow.${colors.reset}\n`);
  }

  private handleElevateCommand(args?: string): void {
    const store = {
      get: (key: string) => this.config.session.getConfig(key),
      set: (key: string, value: string) => this.config.session.setConfig(key, value),
      delete: (key: string) => this.config.session.deleteConfig(key),
    };
    const raw = (args ?? '').trim();
    if (!raw) {
      const status = getElevationStatusMessage(store, { reasonSeparator: ' - ' });
      console.log(`${colors.gray}${status}${colors.reset}\n`);
      return;
    }
    const output = setElevationFromInput(store, raw);
    console.log(`${colors.gray}${output}${colors.reset}\n`);
  }

  private handleModelCommand(name?: string, providerModel?: string): void {
    const router = this.config.modelRouter;
    if (!router) {
      console.log(`${colors.gray}Model switching not available.${colors.reset}\n`);
      return;
    }

    // /model <tier> <provider/model> — change a tier's provider and model
    if (name && providerModel) {
      const tier = resolveModelAlias(name);
      const slashIdx = providerModel.indexOf('/');
      if (slashIdx === -1) {
        console.log(`${colors.gray}Invalid format. Use provider/model (e.g. anthropic/claude-sonnet-4)${colors.reset}\n`);
        return;
      }
      const provider = providerModel.slice(0, slashIdx);
      const model = providerModel.slice(slashIdx + 1);

      if (!MODEL_PROVIDERS.includes(provider as ModelProvider)) {
        console.log(`${colors.gray}Unknown provider "${provider}". Known providers: ${MODEL_PROVIDERS.join(', ')}${colors.reset}\n`);
        return;
      }

      try {
        const providerType = provider as ModelProvider;
        const template = this.config.modelProviderConfigs?.[providerType];
        const client = createClientFromConfig({
          ...(template ?? {}),
          provider: providerType,
          model,
        });
        router.setClient(tier, client, providerModel);
        router.setTierStrict(tier, true);
        router.setTier(tier);

        if (this.config.agent) {
          this.config.agent.setModelTier(tier);
        }

        console.log(`${colors.gray}Set ${tier} to:${colors.reset} ${providerModel}`);
        console.log(`${colors.gray}Switched to model:${colors.reset} ${tier}`);
        console.log(`${colors.gray}Fallbacks for ${tier} disabled (strict tier mode).${colors.reset}\n`);
      } catch (error) {
        const message = error instanceof Error ? error.message : String(error);
        console.log(`${colors.gray}Failed to create client:${colors.reset} ${message}\n`);
      }
      return;
    }

    // /model — show all tiers with labels
    if (!name) {
      const current = router.getTier();
      const available = router.getAvailableTiers();
      const labels = router.getAllLabels();
      console.log(`${colors.gray}Active tier:${colors.reset} ${current}`);
      for (const tier of available) {
        const label = labels[tier] ?? 'unknown';
        const marker = tier === current ? ' ←' : '';
        console.log(`  ${tier}: ${label}${marker}`);
      }
      console.log();
      return;
    }

    // /model <tier> — switch active tier
    const tier = resolveModelAlias(name);
    if (router.setTier(tier)) {
      // Also update the agent tier so chatWithRouter uses the correct client
      if (this.config.agent) {
        this.config.agent.setModelTier(tier);
      }
      console.log(`${colors.gray}Switched to model:${colors.reset} ${tier}\n`);
    } else {
      console.log(`${colors.gray}Model not available:${colors.reset} ${name}\n`);
    }
  }

  private async handleBackendCommand(provider?: string): Promise<void> {
    const router = this.config.modelRouter;
    if (!router) {
      console.log('Backend switching not available.\n');
      return;
    }

    if (!provider) {
      const current = router.getLocalProviderName() ?? this.config.currentLocalProvider ?? 'unknown';
      const available = this.getAvailableBackends();
      console.log(`Current local backend: ${current}`);
      console.log(`Available: ${available.join(', ')}\n`);
      return;
    }

    const providerConfig = this.config.localProviders?.[provider];
    if (!providerConfig) {
      const available = this.getAvailableBackends();
      console.log(`Backend '${provider}' not configured.`);
      console.log(`Available: ${available.join(', ')}\n`);
      return;
    }

    // Stop current daemon if running
    const currentBackend = router.getLocalProviderName();
    if (currentBackend && currentBackend !== provider) {
      console.log(`${colors.gray}Stopping ${currentBackend}...${colors.reset}`);
      await this.stopBackend(currentBackend);
    }

    // Start new daemon
    console.log(`${colors.gray}Starting ${provider}...${colors.reset}`);
    await this.startBackend(provider, providerConfig);

    const client = this.createLocalClient(providerConfig);
    if (!client) {
      console.log(`Failed to create client for '${provider}'.\n`);
      return;
    }

    router.setLocalClient(client, provider);
    console.log(`${colors.gray}Switched to backend: ${provider}${colors.reset}\n`);
  }

  private async handleRuntimeCommand(input?: string): Promise<void> {
    if (!this.config.onRuntimeCommand) {
      console.log(`${colors.gray}Runtime backend mode command service is unavailable in this TUI session.${colors.reset}`);
      console.log(`${colors.gray}Use 'flynn start' and reconnect.${colors.reset}\n`);
      return;
    }

    try {
      const output = await this.config.onRuntimeCommand(input);
      console.log(`${output}\n`);
    } catch (error) {
      const message = error instanceof Error ? error.message : String(error);
      console.log(`${colors.gray}Runtime command failed:${colors.reset} ${message}\n`);
    }
  }

  private async stopBackend(provider: string): Promise<void> {
    try {
      const { exec } = await import('child_process');
      let serviceName: string;
      switch (provider) {
        case 'ollama':
          serviceName = 'ollama.service';
          break;
        case 'llamacpp':
          serviceName = 'llama-server.service';
          break;
        default:
          return;
      }
      await new Promise<void>((resolve, reject) => {
        exec(`systemctl --user stop ${serviceName}`, (error) => {
          if (error) {
            reject(error);
          } else {
            resolve();
          }
        });
      });
    } catch {
      // Service might not exist or already stopped, ignore
      console.log(`${colors.gray}Note: ${provider} service not managed by systemd${colors.reset}\n`);
    }
  }

  private async startBackend(provider: string, _config: ModelConfig): Promise<void> {
    try {
      const { exec } = await import('child_process');
      let serviceName: string;
      switch (provider) {
        case 'ollama':
          serviceName = 'ollama.service';
          break;
        case 'llamacpp':
          serviceName = 'llama-server.service';
          break;
        default:
          return;
      }
      await new Promise<void>((resolve, reject) => {
        exec(`systemctl --user start ${serviceName}`, (error) => {
          if (error) {
            reject(error);
          } else {
            resolve();
          }
        });
      });

      // Wait briefly for daemon to start
      await new Promise(resolve => setTimeout(resolve, 500));
    } catch (error) {
      console.log(`${colors.gray}Warning: Failed to start ${provider} via systemd: ${error instanceof Error ? error.message : String(error)}${colors.reset}\n`);
    }
  }

  private async handleLoginCommand(provider?: string): Promise<void> {
    const target = provider ?? 'github';
    const confirmReplace = async (): Promise<boolean> => {
      const answer = (await this.prompt(
        `${colors.orange}Re-authenticate and replace it?${colors.reset} ${colors.gray}(y/N)${colors.reset} `,
      )).trim().toLowerCase();
      return answer === 'y' || answer === 'yes';
    };

    const promptHidden = async (question: string): Promise<string> => {
      const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: true });
      const rlAny = rl as readline.Interface & {
        stdoutMuted?: boolean;
        _writeToOutput?: (s: string) => void;
      };
      const stdin = process.stdin as NodeJS.ReadStream & {
        isRaw?: boolean;
        setRawMode?: (mode: boolean) => void;
      };
      const wasRaw = Boolean(stdin.isRaw);
      let enabledRawForPrompt = false;
      let dataListener: ((chunk: Buffer) => void) | null = null;
      let settled = false;

      if (stdin.isTTY && stdin.setRawMode && !wasRaw) {
        stdin.setRawMode(true);
        enabledRawForPrompt = true;
      }

      const cleanup = () => {
        if (dataListener) {
          stdin.removeListener('data', dataListener);
          dataListener = null;
        }
        if (enabledRawForPrompt && stdin.setRawMode) {
          stdin.setRawMode(false);
        }
      };

      rlAny.stdoutMuted = true;
      rlAny._writeToOutput = (s: string) => {
        if (!rlAny.stdoutMuted) {
          process.stdout.write(s);
          return;
        }
        if (s.includes('\n')) {
          process.stdout.write('\n');
        } else {
          process.stdout.write('*');
        }
      };
      const answer = await new Promise<string>((resolve) => {
        dataListener = (chunk: Buffer) => {
          if (settled) {
            return;
          }
          if (chunk.length === 1 && chunk[0] === 0x1b) {
            settled = true;
            rl.close();
            resolve('');
          }
        };
        stdin.on('data', dataListener);

        const onClose = () => {
          if (settled) {
            return;
          }
          settled = true;
          resolve('');
        };
        if (typeof rl.once === 'function') {
          rl.once('close', onClose);
        }

        rl.question(question, (value) => {
          if (settled) {
            return;
          }
          settled = true;
          resolve(value);
        });
      });
      rlAny.stdoutMuted = false;
      cleanup();
      rl.close();
      process.stdout.write('\n');
      return answer.trim();
    };

    if (!this.rl) {
      console.log(`${colors.gray}TUI not ready for login prompt. Use the CLI auth commands instead.${colors.reset}\n`);
      return;
    }
    if (target === 'github') {
      console.log(`${colors.gray}Starting GitHub OAuth device flow...${colors.reset}`);

      try {
        await loginGitHub((userCode, verificationUri) => {
          console.log('');
          console.log(`${colors.gray}Please visit:${colors.reset} ${verificationUri}`);
          console.log(`${colors.gray}and enter code:${colors.reset} ${userCode}`);
          console.log('');
          console.log(`${colors.gray}Waiting for authorization...${colors.reset}`);
        });

        console.log(`${colors.gray}GitHub authentication successful! Token stored.${colors.reset}\n`);
      } catch (error) {
        const message = error instanceof Error ? error.message : String(error);
        console.log(`${colors.gray}GitHub login failed:${colors.reset} ${message}\n`);
      }

      return;
    }

    if (target === 'openai') {
      console.log(`${colors.gray}OpenAI login:${colors.reset}`);
      console.log(`${colors.gray}  1) OAuth device flow  2) Paste API key${colors.reset}`);
      const choice = (await this.prompt(`${colors.orange}Choose [1-2] (default 1):${colors.reset} `)).trim();

      // 2) API key
      if (choice === '2') {
        const existing = loadStoredOpenAIApiKey();
        if (existing) {
          console.log(`${colors.gray}OpenAI API key already exists.${colors.reset}`);
          if (!await confirmReplace()) {
            console.log(`${colors.gray}Cancelled.${colors.reset}\n`);
            return;
          }
        }

        console.log(`${colors.gray}OpenAI uses API keys for standard API access.${colors.reset}`);
        console.log(`${colors.gray}Create a key at:${colors.reset} https://platform.openai.com/api-keys`);
        console.log('');

        try {
          this.rl.pause();
          const apiKey = await promptHidden('Enter OpenAI API key: ');
          storeOpenAIApiKey(apiKey);
          console.log('');
          console.log(`${colors.gray}OpenAI API key stored in ~/.config/flynn/auth.json${colors.reset}\n`);
        } catch (error) {
          const message = error instanceof Error ? error.message : String(error);
          console.log(`${colors.gray}OpenAI API key storage failed:${colors.reset} ${message}\n`);
        } finally {
          this.rl.resume();
        }

        return;
      }

      // 1) OAuth device flow (default)
      const existing = loadStoredOpenAIAuth();
      if (existing) {
        console.log(`${colors.gray}OpenAI OAuth token already exists.${colors.reset}`);
        if (!await confirmReplace()) {
          console.log(`${colors.gray}Cancelled.${colors.reset}\n`);
          return;
        }
      }

      console.log(`${colors.gray}Starting OpenAI OAuth device flow...${colors.reset}`);

      try {
        await loginOpenAI((userCode, verificationUri) => {
          console.log('');
          console.log(`${colors.gray}Please visit:${colors.reset} ${verificationUri}`);
          console.log(`${colors.gray}and enter code:${colors.reset} ${userCode}`);
          console.log('');
          console.log(`${colors.gray}Waiting for authorization...${colors.reset}`);
        });

        console.log(`${colors.gray}OpenAI authentication successful! Token stored.${colors.reset}\n`);
      } catch (error) {
        const message = error instanceof Error ? error.message : String(error);
        console.log(`${colors.gray}OpenAI login failed:${colors.reset} ${message}\n`);
      }

      return;
    }

    if (target === 'anthropic') {
      console.log(`${colors.gray}Anthropic login:${colors.reset}`);
      console.log(`${colors.gray}  1) Paste API key  2) Paste auth token${colors.reset}`);
      const choice = (await this.prompt(`${colors.orange}Choose [1-2] (default 1):${colors.reset} `)).trim();

      const existing = loadStoredAnthropicAuth();
      const hasApiKey = Boolean(existing?.api_key);
      const hasToken = Boolean(loadStoredAnthropicAuthToken());

      // 2) Auth token
      if (choice === '2') {
        if (hasToken) {
          console.log(`${colors.gray}Anthropic auth token already exists.${colors.reset}`);
          if (!await confirmReplace()) {
            console.log(`${colors.gray}Cancelled.${colors.reset}\n`);
            return;
          }
        }

        console.log(`${colors.gray}Anthropic supports token-style auth (provider-specific).${colors.reset}`);
        console.log('');

        try {
          this.rl.pause();
          const token = await promptHidden('Enter Anthropic auth token: ');
          storeAnthropicAuthToken(token);
          console.log('');
          console.log(`${colors.gray}Anthropic auth token stored in ~/.config/flynn/auth.json${colors.reset}\n`);
        } catch (error) {
          const message = error instanceof Error ? error.message : String(error);
          console.log(`${colors.gray}Anthropic auth failed:${colors.reset} ${message}\n`);
        } finally {
          this.rl.resume();
        }

        return;
      }

      // 1) API key (default)
      if (hasApiKey) {
        console.log(`${colors.gray}Anthropic API key already exists.${colors.reset}`);
        if (!await confirmReplace()) {
          console.log(`${colors.gray}Cancelled.${colors.reset}\n`);
          return;
        }
      }

      console.log(`${colors.gray}Anthropic uses API keys for authentication.${colors.reset}`);
      console.log(`${colors.gray}Create a key at:${colors.reset} https://console.anthropic.com/settings/keys`);
      console.log('');

      try {
        this.rl.pause();
        const apiKey = await promptHidden('Enter Anthropic API key: ');
        storeAnthropicAuth(apiKey);
        console.log('');
        console.log(`${colors.gray}Anthropic API key stored in ~/.config/flynn/auth.json${colors.reset}\n`);
      } catch (error) {
        const message = error instanceof Error ? error.message : String(error);
        console.log(`${colors.gray}Anthropic auth failed:${colors.reset} ${message}\n`);
      } finally {
        this.rl.resume();
      }

      return;
    }

    if (target === 'zai' || target === 'zhipuai') {
      const existing = loadStoredZaiAuth();
      if (existing) {
        console.log(`${colors.gray}Z.AI credential already exists.${colors.reset}`);
        if (!await confirmReplace()) {
          console.log(`${colors.gray}Cancelled.${colors.reset}\n`);
          return;
        }
      }

      console.log(`${colors.gray}Z.AI uses API keys (HTTP Bearer), not an OAuth device flow.${colors.reset}`);
      console.log(`${colors.gray}Create a key at:${colors.reset} https://z.ai/manage-apikey/apikey-list`);
      console.log(`${colors.gray}Choose mode: 1) API  2) Coding Plan${colors.reset}`);
      const choice = (await this.prompt(`${colors.orange}Select [1-2] (default 1):${colors.reset} `)).trim().toLowerCase();
      const mode = (choice === '2' || choice === 'plan') ? 'plan' : 'api';
      console.log('');

      try {
        this.rl.pause();
        const apiKey = await promptHidden('Enter Z.AI API key: ');
        storeZaiAuth(apiKey);
        console.log('');
        console.log(`${colors.gray}Z.AI credential stored in ~/.config/flynn/auth.json${colors.reset}`);
        if (mode === 'plan') {
          console.log(`${colors.gray}Mode: Coding Plan${colors.reset}`);
          console.log(`${colors.gray}Set endpoint to https://api.z.ai/api/coding/paas/v4${colors.reset}\n`);
        } else {
          console.log(`${colors.gray}Mode: API${colors.reset}`);
          console.log(`${colors.gray}Set endpoint to https://api.z.ai/api/paas/v4${colors.reset}\n`);
        }
      } catch (error) {
        const message = error instanceof Error ? error.message : String(error);
        console.log(`${colors.gray}Z.AI auth failed:${colors.reset} ${message}\n`);
      } finally {
        this.rl.resume();
      }

      return;
    }

    console.log(`${colors.gray}Unknown login provider:${colors.reset} ${target}. Supported: github, openai, anthropic, zai\n`);
  }

  private handlePairCommand(action?: 'generate' | 'list' | 'revoke', args?: string): void {
    const pm = this.config.pairingManager;
    if (!pm) {
      console.log(`${colors.gray}Pairing not enabled. Set pairing.enabled: true in config.${colors.reset}\n`);
      return;
    }

    switch (action) {
      case 'generate': {
        const code = pm.generateCode(args);
        const pending = pm.listPendingCodes().find(p => p.code === code);
        const expiresIn = pending ? Math.round((pending.expiresAt - Date.now()) / 1000) : '?';
        console.log(`${colors.bold}Pairing code: ${code}${colors.reset}`);
        console.log(`${colors.gray}Expires in ${expiresIn}s${args ? ` (label: ${args})` : ''}${colors.reset}\n`);
        break;
      }

      case 'revoke': {
        if (!args) {
          console.log(`${colors.gray}Usage: /pair revoke <channel> <senderId>${colors.reset}\n`);
          return;
        }
        const parts = args.split(/\s+/);
        if (parts.length < 2) {
          console.log(`${colors.gray}Usage: /pair revoke <channel> <senderId>${colors.reset}\n`);
          return;
        }
        const [channel, senderId] = parts;
        const revoked = pm.revokeApproval(channel, senderId);
        if (revoked) {
          console.log(`${colors.bold}Revoked approval for ${channel}:${senderId}${colors.reset}\n`);
        } else {
          console.log(`${colors.gray}No approval found for ${channel}:${senderId}${colors.reset}\n`);
        }
        break;
      }

      case 'list':
      default: {
        const pending = pm.listPendingCodes();
        const approved = pm.listApproved();

        if (pending.length === 0 && approved.length === 0) {
          console.log(`${colors.gray}No pending codes or approved senders.${colors.reset}\n`);
          return;
        }

        if (pending.length > 0) {
          console.log(`${colors.bold}Pending codes:${colors.reset}`);
          for (const p of pending) {
            const expiresIn = Math.round((p.expiresAt - Date.now()) / 1000);
            console.log(`  ${p.code}  expires in ${expiresIn}s${p.label ? `  (${p.label})` : ''}`);
          }
        }

        if (approved.length > 0) {
          console.log(`${colors.bold}Approved senders:${colors.reset}`);
          for (const a of approved) {
            const date = new Date(a.approvedAt).toISOString().slice(0, 16).replace('T', ' ');
            console.log(`  ${a.channel}:${a.senderId}  since ${date}  (code: ${a.codeUsed})`);
          }
        }
        console.log('');
        break;
      }
    }
  }

  private getAvailableBackends(): string[] {
    const backends: string[] = [];
    if (this.config.currentLocalProvider) {
      backends.push(this.config.currentLocalProvider);
    }
    if (this.config.localProviders) {
      backends.push(...Object.keys(this.config.localProviders));
    }
    return [...new Set(backends)];
  }

  private createLocalClient(config: ModelConfig): ModelClient | null {
    if (config.provider === 'ollama') {
      return new OllamaClient({
        model: config.model,
        host: config.endpoint,
      });
    }
    if (config.provider === 'llamacpp') {
      return new LlamaCppClient({
        endpoint: config.endpoint ?? 'http://localhost:8080',
        model: config.model,
        authToken: config.auth_token,
      });
    }
    return null;
  }

  private printStatus(): void {
    console.log(`${colors.gray}Session:${colors.reset} ${this.config.session.id}`);
    console.log(`${colors.gray}Messages:${colors.reset} ${this.config.session.getHistory().length}`);
    console.log(`${colors.gray}Tokens:${colors.reset} ${this.totalUsage.inputTokens} in / ${this.totalUsage.outputTokens} out\n`);
  }

  private async handleMessage(content: string): Promise<void> {
    const clearedPromptLine = this.clearSubmittedPromptLine();
    const userTimestamp = formatMessageTimestampParts(Date.now());
    process.stdout.write(
      `${clearedPromptLine ? '' : '\n'}${colors.blue}${colors.bold}You${colors.reset} ${colors.gray}[${userTimestamp.date} | ${userTimestamp.time}]${colors.reset}\n`,
    );
    process.stdout.write(`${content}\n`);

    const assistantTimestamp = formatMessageTimestampParts(Date.now());
    // Print Flynn label before response
    process.stdout.write(
      `\n${colors.orange}${colors.bold}Flynn${colors.reset} ${colors.gray}[${assistantTimestamp.date} | ${assistantTimestamp.time}]${colors.reset}\n`,
    );
    this.startBusyIndicator();

    try {
      if (this.config.onTools && isToolInventoryQuery(content)) {
        this.stopBusyIndicator();
        console.log(this.config.onTools());
        console.log();
        return;
      }

      // Use agent if available (supports tool loop)
      if (this.config.agent) {
        let cancelRequested = false;
        this.activeOperationCancel = () => {
          if (cancelRequested) {
            return;
          }
          cancelRequested = true;
          this.config.agent?.cancel();
          console.log(`\n${colors.gray}Cancelling...${colors.reset}`);
        };
        const response = await this.config.agent.process(content);
        this.activeOperationCancel = null;
        this.stopBusyIndicator();
        const rendered = renderMarkdown(response);
        console.log(rendered);
        console.log();
        return;
      }

      // Fallback: direct model client (no tool support)
      this.config.session.addMessage({ role: 'user', content });

      // Try streaming if available
      if (this.config.modelClient.chatStream) {
        let fullContent = '';
        let streamStarted = false;
        let cancelRequested = false;
        this.activeOperationCancel = () => {
          if (cancelRequested) {
            return;
          }
          cancelRequested = true;
          console.log(`\n${colors.gray}Cancelling...${colors.reset}`);
        };

        for await (const event of this.config.modelClient.chatStream({
          messages: this.config.session.getHistory(),
          system: this.config.systemPrompt,
        })) {
          if (cancelRequested) {
            fullContent += '\n\n[interrupted]';
            break;
          }
          if (event.type === 'content' && event.content) {
            if (!streamStarted) {
              streamStarted = true;
              this.stopBusyIndicator();
            }
            process.stdout.write(event.content);
            fullContent += event.content;
          }
          if (event.type === 'fallback_warning' && event.fallbackReason) {
            if (!streamStarted) {
              streamStarted = true;
              this.stopBusyIndicator();
            }
            console.warn('\n⚠ Using fallback model');
          }
          if (event.type === 'done' && event.usage) {
            this.totalUsage.inputTokens += event.usage.inputTokens;
            this.totalUsage.outputTokens += event.usage.outputTokens;
          }
          if (event.type === 'error') {
            throw event.error ?? new Error('Stream error');
          }
        }
        this.activeOperationCancel = null;

        console.log('\n');

        this.config.session.addMessage({ role: 'assistant', content: fullContent });
      } else {
        this.activeOperationCancel = () => {
          console.log(`\n${colors.gray}Cancellation is not available for non-streaming responses.${colors.reset}`);
        };
        // Fallback to non-streaming
        const response = await this.config.modelClient.chat({
          messages: this.config.session.getHistory(),
          system: this.config.systemPrompt,
        });
        this.activeOperationCancel = null;
        this.stopBusyIndicator();

        const rendered = renderMarkdown(response.content);
        console.log(rendered);
        console.log();

        this.totalUsage.inputTokens += response.usage.inputTokens;
        this.totalUsage.outputTokens += response.usage.outputTokens;

        this.config.session.addMessage({ role: 'assistant', content: response.content });
      }
    } catch (error) {
      this.stopBusyIndicator();
      console.error('Error:', error instanceof Error ? error.message : error);
      console.log();
    } finally {
      this.stopBusyIndicator();
      this.activeOperationCancel = null;
    }
  }

  stop(preserveStdin = false): void {
    this.running = false;
    this.activePromptCancel = null;
    this.activeOperationCancel = null;
    if (this.rl) {
      if (preserveStdin) {
        // Remove readline listeners but don't close stdin
        this.rl.removeAllListeners();
        if (this.keypressHandler) {
          process.stdin.removeListener('keypress', this.keypressHandler);
        }
        // Pause stdin so readline releases it
        process.stdin.pause();
      }
      this.rl.close();
      this.rl = null;
    }
    // Clean up keypress listener
    if (this.keypressHandler) {
      process.stdin.removeListener('keypress', this.keypressHandler);
      this.keypressHandler = null;
    }
  }
}
