/**
 * Bloby pi harness — public surface (mirrors `harnesses/claude.ts`).
 *
 * The dispatcher in `supervisor/bloby-agent.ts` imports this module as
 * `* as pi` and selects it when `cfg.ai.provider === 'pi'`. The surface
 * matches the Claude harness so the dispatcher needs no provider-specific
 * code.
 *
 * Phase 1 scope: live conversation + one-shot text only (no tools). The
 * non-blocking feel — user keeps typing while the model is still answering —
 * comes from the same `AsyncQueue` pattern Claude uses; see `async-queue.ts`.
 */
import { log } from '../../../shared/logger.js';
import { WORKSPACE_DIR } from '../../../shared/paths.js';
import type { SavedFile } from '../../file-saver.js';
import { assembleSystemPrompt } from '../../../worker/prompts/prompt-assembler.js';
import fs from 'fs';
import path from 'path';
import type {
  RecentMessage,
  AgentAttachment,
  AgentQueryRequest,
  AgentQueryResult,
} from '../types.js';
export type { RecentMessage, AgentAttachment };

import { createAsyncQueue, type AsyncQueue } from './async-queue.js';
import { createPiSession, type PiSessionEvent } from './session.js';
import { getPiSubProvider } from './sub-providers.js';
import { readPiAuth } from './auth-storage.js';
import { streamProvider } from './providers/stream.js';
import type { PiMessage } from './providers/types.js';
import { toolDefsForProvider } from './tools/registry.js';

// ── Live conversation state ────────────────────────────────────────────────

interface LiveConversation {
  id: string;
  inputQueue: AsyncQueue<PiMessage>;
  abortController: AbortController;
  onMessage: (type: string, data: any) => void;
  busy: boolean;
  loopDone: Promise<void> | null;
}

const liveConversations = new Map<string, LiveConversation>();

export function hasConversation(conversationId: string): boolean {
  return liveConversations.has(conversationId);
}

export function endAllConversations(): void {
  for (const id of liveConversations.keys()) {
    log.info(`[pi/conversation] Ending conversation ${id} (bulk end)`);
    endConversation(id);
  }
}

// ── Helpers ─────────────────────────────────────────────────────────────────

function readMemoryFile(filename: string): string {
  try {
    const content = fs.readFileSync(path.join(WORKSPACE_DIR, filename), 'utf-8').trim();
    return content || '(empty)';
  } catch {
    return '(empty)';
  }
}

function readMemoryFiles() {
  return {
    myself: readMemoryFile('MYSELF.md'),
    myhuman: readMemoryFile('MYHUMAN.md'),
    memory: readMemoryFile('MEMORY.md'),
    pulse: readMemoryFile('PULSE.json'),
    crons: readMemoryFile('CRONS.json'),
  };
}

function formatConversationHistory(messages: RecentMessage[]): string {
  if (!messages.length) return '';
  return messages.map((m) => `${m.role}: ${m.content}`).join('\n\n');
}

/**
 * Live-conversation pacing hint. The Claude Agent SDK trains its model to do
 * this natively; non-Anthropic models (Gemini especially) tend to go silent
 * during tool loops and never report progress. This nudge makes the
 * conversation feel alive — quick acknowledgement before long tasks, short
 * status notes between tool calls, and inline answers if the user types
 * something while the agent is mid-task.
 */
const LIVE_CONVERSATION_HINT = `

---
# Live-conversation pacing

You are running in a streaming chat where the user can keep typing while you work. Make the conversation feel alive:

- Before kicking off a multi-step task, say one short line acknowledging it ("On it, looking at the widget now.").
- Between tool calls on long tasks, drop a brief progress note ("Found the file, checking the layout next.") so the user knows you're still working.
- If a new user message arrives while you're mid-task, you'll see it as a fresh user-role message in the conversation history. Answer it briefly inline, mention you're still working on the main task, then continue.
- Final answers should be concise and concrete.`;

async function buildSystemPrompt(
  names?: { botName: string; humanName: string },
  recentMessages?: RecentMessage[],
): Promise<string> {
  const memoryFiles = readMemoryFiles();
  const basePrompt = await assembleSystemPrompt(names?.botName, names?.humanName, 'pi');
  let systemPrompt = basePrompt;
  systemPrompt += LIVE_CONVERSATION_HINT;
  systemPrompt += `\n\n---\n# Your Memory Files\n\n## MYSELF.md\n${memoryFiles.myself}\n\n## MYHUMAN.md\n${memoryFiles.myhuman}\n\n## MEMORY.md\n${memoryFiles.memory}\n\n---\n# Your Config Files\n\n## PULSE.json\n${memoryFiles.pulse}\n\n## CRONS.json\n${memoryFiles.crons}`;

  try {
    const { loadConfig: loadCfg } = await import('../../../shared/config.js');
    const cfg = loadCfg();
    const channels = (cfg as any).channels;
    if (channels) {
      systemPrompt += `\n\n---\n# Channel Config\n\`\`\`json\n${JSON.stringify(channels, null, 2)}\n\`\`\``;
    }
  } catch {}

  if (recentMessages?.length) {
    systemPrompt += `\n\n---\n# Recent Conversation\n${formatConversationHistory(recentMessages)}`;
  }
  return systemPrompt;
}

/** Resolve sub-provider, base url, api key, model id from saved pi-auth.json. */
function resolveAuth(): {
  ok: true;
  flavor: ReturnType<typeof getPiSubProvider> extends undefined ? never : NonNullable<ReturnType<typeof getPiSubProvider>>['flavor'];
  modelId: string;
  baseUrl: string;
  apiKey: string;
} | { ok: false; error: string } {
  const auth = readPiAuth();
  if (!auth) return { ok: false, error: 'Bloby provider is not configured. Run the onboarding wizard.' };
  const sub = getPiSubProvider(auth.subProvider);
  if (!sub) return { ok: false, error: `Unknown sub-provider in pi-auth.json: ${auth.subProvider}` };
  const baseUrl = (auth.baseUrl || sub.baseUrl || '').replace(/\/+$/, '');
  if (!baseUrl) return { ok: false, error: `No base URL configured for ${sub.id}` };
  const modelId = auth.modelId || sub.defaultModel || '';
  if (!modelId) return { ok: false, error: `No model selected for ${sub.id}` };
  if (sub.needsApiKey && !auth.apiKey) return { ok: false, error: `Missing API key for ${sub.id}` };
  return {
    ok: true,
    flavor: sub.flavor,
    modelId,
    baseUrl,
    apiKey: auth.apiKey || '',
  };
}

/** Convert a saved RecentMessage[] into the provider-neutral PiMessage[]. */
function recentToPiMessages(messages: RecentMessage[] | undefined): PiMessage[] {
  if (!messages?.length) return [];
  return messages.map((m) => ({
    role: m.role === 'assistant' ? 'assistant' : 'user',
    content: [{ type: 'text', text: m.content }],
  }));
}

/** Wrap a raw user input into a PiMessage with text + optional image blocks. */
function buildUserMessage(text: string, attachments?: AgentAttachment[], savedFiles?: SavedFile[]): PiMessage {
  const content: PiMessage['content'] = [];
  if (attachments?.length) {
    for (const att of attachments) {
      if (att.type === 'image') {
        content.push({ type: 'image', mediaType: att.mediaType, data: att.data });
      } else {
        // Documents aren't directly supported across all sub-providers yet.
        // Surface their existence in the text body instead.
        content.push({ type: 'text', text: `[Attached document: ${att.name} (${att.mediaType})]` });
      }
    }
  }
  let prompt = text || '(attached files)';
  if (savedFiles?.length) {
    const lines = savedFiles.map((f) => `- ${f.name} -> ${f.relPath}`);
    prompt += `\n\n[Attached files saved to disk]\n${lines.join('\n')}\nYou can read or reference these files using the paths above (relative to your cwd).`;
  }
  content.push({ type: 'text', text: prompt });
  return { role: 'user', content };
}

// ── Live Conversation API ──────────────────────────────────────────────────

export async function startConversation(
  conversationId: string,
  _model: string,
  onMessage: (type: string, data: any) => void,
  names?: { botName: string; humanName: string },
  recentMessages?: RecentMessage[],
): Promise<boolean> {
  log.info(`[pi/conversation] ──── STARTING CONVERSATION ────`);
  log.info(`[pi/conversation] Conv ID: ${conversationId}`);

  if (liveConversations.has(conversationId)) {
    log.info(`[pi/conversation] Ending existing conversation ${conversationId} before starting new one`);
    endConversation(conversationId);
  }

  const auth = resolveAuth();
  if (!auth.ok) {
    log.warn(`[pi/conversation] Cannot start: ${auth.error}`);
    onMessage('bot:error', { conversationId, error: auth.error });
    return false;
  }

  log.info(`[pi/conversation] Sub-provider: ${auth.flavor} · model: ${auth.modelId}`);

  const systemPrompt = await buildSystemPrompt(names, recentMessages);
  log.info(`[pi/conversation] System prompt: ${systemPrompt.length} chars`);

  const inputQueue = createAsyncQueue<PiMessage>();
  const abortController = new AbortController();

  const conv: LiveConversation = {
    id: conversationId,
    inputQueue,
    abortController,
    onMessage,
    busy: false,
    loopDone: null,
  };
  liveConversations.set(conversationId, conv);

  const session = createPiSession({
    flavor: auth.flavor,
    modelId: auth.modelId,
    baseUrl: auth.baseUrl,
    apiKey: auth.apiKey,
    systemPrompt,
    tools: toolDefsForProvider(),
    cwd: WORKSPACE_DIR,
    abortController,
    onEvent: (evt: PiSessionEvent) => {
      translateAndEmit(conv, evt);
    },
  });

  conv.loopDone = (async () => {
    try {
      await session.run(inputQueue);
      log.info(`[pi/conversation] ──── QUERY LOOP ENDED ────`);
    } catch (err: any) {
      if (!abortController.signal.aborted) {
        log.warn(`[pi/conversation] Loop error: ${err?.message || err}`);
        onMessage('bot:error', { conversationId, error: err?.message || String(err) });
      }
    } finally {
      log.info(`[pi/conversation] Cleaning up conversation ${conversationId}`);
      liveConversations.delete(conversationId);
      onMessage('bot:conversation-ended', { conversationId });
    }
  })();

  return true;
}

/** Map session-level events back into bloby's `bot:*` vocabulary. */
function translateAndEmit(conv: LiveConversation, evt: PiSessionEvent) {
  switch (evt.type) {
    case 'turn_started':
      // No bloby event for this — `bot:typing` is already emitted by pushMessage().
      break;
    case 'text_delta':
      conv.onMessage('bot:token', { conversationId: conv.id, token: evt.delta });
      break;
    case 'text_end':
      conv.onMessage('bot:response', { conversationId: conv.id, content: evt.text });
      break;
    case 'tool_use':
      conv.onMessage('bot:tool', { conversationId: conv.id, name: evt.name, input: evt.input });
      break;
    case 'turn_complete':
      conv.busy = false;
      conv.onMessage('bot:turn-complete', { conversationId: conv.id, usedFileTools: evt.usedFileTools });
      log.info(`[pi/conversation] ──── TURN COMPLETE ────  busy=false`);
      break;
    case 'error':
      conv.busy = false;
      conv.onMessage('bot:error', { conversationId: conv.id, error: evt.error });
      break;
  }
}

export function pushMessage(
  conversationId: string,
  content: string,
  attachments?: AgentAttachment[],
  savedFiles?: SavedFile[],
): boolean {
  const conv = liveConversations.get(conversationId);
  if (!conv) {
    log.warn(`[pi/conversation] pushMessage — no live conversation ${conversationId}`);
    return false;
  }

  log.info(`[pi/conversation] ──── PUSH MESSAGE ──── busy=${conv.busy}`);
  conv.busy = true;
  conv.inputQueue.push(buildUserMessage(content, attachments, savedFiles));
  conv.onMessage('bot:typing', { conversationId });
  return true;
}

export function endConversation(conversationId: string): void {
  const conv = liveConversations.get(conversationId);
  if (!conv) return;

  log.info(`[pi/conversation] ──── ENDING CONVERSATION ${conversationId} ────`);
  conv.inputQueue.end();
  conv.abortController.abort();
  liveConversations.delete(conversationId);
}

export function isConversationBusy(conversationId: string): boolean {
  return liveConversations.get(conversationId)?.busy || false;
}

/** True if ANY live conversation in this harness is mid-turn. Used by the supervisor to defer
 *  backend restarts during channel/Alexa turns (which don't set the dashboard's agentQueryActive). */
export function anyConversationBusy(): boolean {
  for (const c of liveConversations.values()) if (c.busy) return true;
  return false;
}

/** Pi has no sub-agents yet; provided for interface compatibility. */
export async function stopSubAgentTask(_conversationId: string, _taskId: string): Promise<void> {
  // no-op for Phase 1
}

/** Pi has no pre-warm step (no subprocess), but the interface requires this. */
export async function warmUpForLiveConversation(
  _model: string,
  _names?: { botName: string; humanName: string },
): Promise<void> {
  // no-op
}

// ── One-shot API (customer WhatsApp, scheduler, /api/agent/query) ──────────

const activeQueries = new Map<string, AbortController>();

/** True while any one-shot startBlobyAgentQuery is in flight (cleared in a finally). These don't
 *  register as live conversations, so anyConversationBusy() can't see them. */
export function anyOneShotActive(): boolean {
  return activeQueries.size > 0;
}

/**
 * One-shot text query — used by customer WhatsApp + scheduler. Uses the
 * provider stream directly (no async queue), drains it, emits the same
 * bloby events the live path does.
 */
export async function startBlobyAgentQuery(
  conversationId: string,
  prompt: string,
  _model: string,
  onMessage: (type: string, data: any) => void,
  attachments?: AgentAttachment[],
  savedFiles?: SavedFile[],
  names?: { botName: string; humanName: string },
  recentMessages?: RecentMessage[],
  supportPrompt?: string,
  _maxTurns?: number,
): Promise<void> {
  const auth = resolveAuth();
  if (!auth.ok) {
    onMessage('bot:error', { conversationId, error: auth.error });
    return;
  }

  const abortController = new AbortController();
  activeQueries.set(conversationId, abortController);
  // Hard watchdog — a hung provider stream would otherwise pin this query forever (finally never
  // runs, bot:done never fires). Abort after 5 min; cleared in the finally on normal completion.
  const watchdog = setTimeout(() => {
    log.warn(`[pi/bloby-agent] one-shot timed out (5m) — aborting conv=${conversationId}`);
    abortController.abort();
  }, 300_000);

  let systemPrompt: string;
  if (supportPrompt) {
    systemPrompt = supportPrompt;
  } else {
    systemPrompt = await buildSystemPrompt(names, recentMessages);
  }

  const messages: PiMessage[] = recentToPiMessages(recentMessages);
  messages.push(buildUserMessage(prompt, attachments, savedFiles));

  onMessage('bot:typing', { conversationId });

  let accumulated = '';
  const usedTools = new Set<string>();
  let errored = false;

  try {
    const stream = streamProvider(auth.flavor, {
      modelId: auth.modelId,
      baseUrl: auth.baseUrl,
      apiKey: auth.apiKey,
      systemPrompt,
      messages,
      signal: abortController.signal,
    });

    for await (const evt of stream) {
      if (abortController.signal.aborted) break;
      switch (evt.type) {
        case 'text_delta':
          accumulated += evt.delta;
          onMessage('bot:token', { conversationId, token: evt.delta });
          break;
        case 'text_end':
          accumulated = evt.text;
          break;
        case 'tool_use':
          usedTools.add(evt.name);
          onMessage('bot:tool', { conversationId, name: evt.name, input: evt.input });
          break;
        case 'error':
          errored = true;
          onMessage('bot:error', { conversationId, error: evt.error });
          break;
      }
    }
    if (accumulated && !errored) {
      onMessage('bot:response', { conversationId, content: accumulated });
    }
  } catch (err: any) {
    if (!abortController.signal.aborted) {
      log.warn(`[pi/bloby-agent] one-shot error: ${err?.message || err}`);
      onMessage('bot:error', { conversationId, error: err?.message || String(err) });
    }
  } finally {
    clearTimeout(watchdog);
    activeQueries.delete(conversationId);
    const FILE_TOOL_NAMES = ['Write', 'Edit', 'write', 'edit'];
    const usedFileTools = FILE_TOOL_NAMES.some((t) => usedTools.has(t));
    onMessage('bot:done', { conversationId, usedFileTools });
  }
}

export function stopBlobyAgentQuery(conversationId: string): void {
  const ctl = activeQueries.get(conversationId);
  if (ctl) {
    ctl.abort();
    activeQueries.delete(conversationId);
  }
}

// ── Workspace agent endpoint (POST /api/agent/query) ──────────────────────

export async function runAgentQuery(req: AgentQueryRequest): Promise<AgentQueryResult> {
  const auth = resolveAuth();
  if (!auth.ok) return { ok: false, error: auth.error };

  const timeout = Math.min(Math.max(req.timeout || 120_000, 5_000), 300_000);
  const abortController = new AbortController();
  const timeoutHandle = setTimeout(() => abortController.abort(), timeout);

  const systemPrompt = req.systemPrompt ?? '';
  const messages: PiMessage[] = [{
    role: 'user',
    content: [{ type: 'text', text: req.message }],
  }];

  let fullText = '';
  const usedTools = new Set<string>();
  let errored = false;
  let errorMsg = '';

  try {
    const stream = streamProvider(auth.flavor, {
      modelId: auth.modelId,
      baseUrl: auth.baseUrl,
      apiKey: auth.apiKey,
      systemPrompt,
      messages,
      signal: abortController.signal,
    });

    for await (const evt of stream) {
      if (abortController.signal.aborted) break;
      switch (evt.type) {
        case 'text_delta':
          fullText += evt.delta;
          break;
        case 'text_end':
          fullText = evt.text;
          break;
        case 'tool_use':
          usedTools.add(evt.name);
          break;
        case 'error':
          errored = true;
          errorMsg = evt.error;
          break;
      }
    }
  } catch (err: any) {
    if (abortController.signal.aborted) {
      return { ok: false, error: 'Query timed out.' };
    }
    return { ok: false, error: err?.message || String(err) };
  } finally {
    clearTimeout(timeoutHandle);
  }

  if (errored) return { ok: false, error: errorMsg || 'Agent query failed' };

  const usedFileTools = ['Write', 'Edit', 'write', 'edit'].some((t) => usedTools.has(t));
  return { ok: true, response: fullText, toolsUsed: Array.from(usedTools), usedFileTools };
}
