/**
 * Pi agent session — the live conversation loop.
 *
 * Mirrors the *shape* of the Claude harness loop in `harnesses/claude.ts`:
 *   - one long-lived session per conversation
 *   - user messages arrive via an `AsyncQueue<PiMessage>` input
 *   - the loop drains the queue one turn at a time
 *   - each turn streams provider events back through a single `onEvent`
 *     callback the caller hooked up
 *
 * Phase 2: each user turn is an inner loop — provider call → if the model
 * asked for tool calls, execute them and feed results back → call provider
 * again — until the model finishes without requesting more tools. Tokens
 * stream live; `text_end` only fires once at the very end of the turn so the
 * UI doesn't display half-answers between tool rounds.
 *
 * Sub-agents are NOT spawned here — Bruno will add those later.
 */
import { log } from '../../../shared/logger.js';
import type { PiApiFlavor } from './sub-providers.js';
import { streamProvider } from './providers/stream.js';
import type { PiMessage, PiStreamEvent, PiToolDef, PiContentBlock } from './providers/types.js';
import type { AsyncQueue } from './async-queue.js';
import { findTool } from './tools/registry.js';
import type { PiTool } from './tools/types.js';

export type PiSessionEvent =
  | { type: 'turn_started' }
  | { type: 'text_delta'; delta: string }
  | { type: 'text_end'; text: string }
  | { type: 'tool_use'; id: string; name: string; input: any }
  | { type: 'tool_result'; toolUseId: string; name: string; isError?: boolean }
  | { type: 'turn_complete'; usedFileTools: boolean }
  | { type: 'error'; error: string };

export interface PiSessionInit {
  flavor: PiApiFlavor;
  modelId: string;
  baseUrl: string;
  apiKey: string;
  systemPrompt: string;
  /** Pre-loaded history before the first new user turn. */
  initialMessages?: PiMessage[];
  /** Tools the model can call this session. Empty array ⇒ chat-only. */
  tools?: PiToolDef[];
  /** Resolved every time a tool fires (registry → run). */
  cwd: string;
  maxOutputTokens?: number;
  /** Used to interrupt in-flight provider calls when the session ends. */
  abortController: AbortController;
  /** Caller's event sink — translated to bloby's `bot:*` events one layer up. */
  onEvent: (evt: PiSessionEvent) => void;
}

export interface PiSession {
  /** Resolves when the loop exits (queue closed or aborted). */
  run(input: AsyncQueue<PiMessage>): Promise<void>;
  /** Cumulative history including prefilled context and live turns. */
  getMessages(): PiMessage[];
}

const FILE_TOOL_NAMES = new Set(['Write', 'Edit', 'MultiEdit', 'NotebookEdit', 'write', 'edit', 'multiEdit', 'notebookEdit']);
const MAX_TOOL_ROUNDS = 25;

export function createPiSession(init: PiSessionInit): PiSession {
  const messages: PiMessage[] = init.initialMessages ? [...init.initialMessages] : [];

  /** One stream round — collect the assistant blocks the model emits this pass. */
  interface RoundResult {
    text: string;
    toolUses: { id: string; name: string; input: any; thoughtSignature?: string }[];
    errored: boolean;
  }

  async function runOneRound(): Promise<RoundResult> {
    const result: RoundResult = { text: '', toolUses: [], errored: false };
    try {
      const stream = streamProvider(init.flavor, {
        modelId: init.modelId,
        baseUrl: init.baseUrl,
        apiKey: init.apiKey,
        systemPrompt: init.systemPrompt,
        messages,
        tools: init.tools,
        maxOutputTokens: init.maxOutputTokens,
        signal: init.abortController.signal,
      });

      for await (const evt of stream as AsyncIterable<PiStreamEvent>) {
        if (init.abortController.signal.aborted) break;
        switch (evt.type) {
          case 'text_delta':
            result.text += evt.delta;
            init.onEvent({ type: 'text_delta', delta: evt.delta });
            break;
          case 'text_end':
            // Sync up with the provider's authoritative concatenation in case
            // we missed a delta. Don't forward — we only emit text_end once
            // at the end of the whole turn so the UI doesn't show half-answers.
            result.text = evt.text;
            break;
          case 'tool_use':
            result.toolUses.push({
              id: evt.id,
              name: evt.name,
              input: evt.input,
              thoughtSignature: evt.thoughtSignature,
            });
            init.onEvent({ type: 'tool_use', id: evt.id, name: evt.name, input: evt.input });
            break;
          case 'error':
            result.errored = true;
            init.onEvent({ type: 'error', error: evt.error });
            break;
          case 'done':
            // Loop control is by tool_use presence, not stop reason.
            break;
        }
      }
    } catch (err: any) {
      if (!init.abortController.signal.aborted) {
        result.errored = true;
        init.onEvent({ type: 'error', error: err?.message || String(err) });
      }
    }
    return result;
  }

  async function executeTool(call: { id: string; name: string; input: any }): Promise<{ output: string; isError?: boolean }> {
    const tool: PiTool | undefined = findTool(call.name);
    if (!tool) {
      return {
        output: `Tool not found: ${call.name}. Available tools: ${(init.tools || []).map((t) => t.name).join(', ') || 'none'}.`,
        isError: true,
      };
    }
    try {
      return await tool.run(call.input, { cwd: init.cwd, signal: init.abortController.signal });
    } catch (err: any) {
      return { output: `Tool ${call.name} threw: ${err?.message || err}`, isError: true };
    }
  }

  async function runOneTurn(input: AsyncQueue<PiMessage>, firstUserMsg: PiMessage): Promise<void> {
    if (init.abortController.signal.aborted) return;
    // Stack any messages that arrived in the same millisecond into one turn.
    messages.push(firstUserMsg);
    for (const extra of input.drainPending()) messages.push(extra);
    init.onEvent({ type: 'turn_started' });

    let accumulatedText = '';
    const usedTools = new Set<string>();
    let turnErrored = false;
    let pendingInterleave = false;

    for (let round = 0; round < MAX_TOOL_ROUNDS; round++) {
      if (init.abortController.signal.aborted) break;
      const { text, toolUses, errored } = await runOneRound();

      // Append whatever the model produced this round to history so subsequent
      // rounds (and the next user turn) see it.
      const assistantContent: PiContentBlock[] = [];
      if (text) {
        accumulatedText += (accumulatedText && !accumulatedText.endsWith('\n') ? '\n\n' : '') + text;
        assistantContent.push({ type: 'text', text });
      }
      for (const tu of toolUses) {
        assistantContent.push({
          type: 'tool_use',
          id: tu.id,
          name: tu.name,
          input: tu.input,
          // Forward Gemini's thoughtSignature unchanged so the next turn's
          // request echoes it back; without it the API rejects with 400.
          thoughtSignature: tu.thoughtSignature,
        });
      }
      if (assistantContent.length > 0) {
        messages.push({ role: 'assistant', content: assistantContent });
      }

      if (errored) { turnErrored = true; break; }

      // Run every tool the model asked for this round, then feed the results
      // back as a single user message Gemini accepts as a batch.
      const toolResultBlocks: PiContentBlock[] = [];
      for (const tu of toolUses) {
        usedTools.add(tu.name);
        if (init.abortController.signal.aborted) break;
        log.info(`[pi/session] tool call ${tu.name}(${JSON.stringify(tu.input).slice(0, 200)})`);
        const res = await executeTool(tu);
        init.onEvent({ type: 'tool_result', toolUseId: tu.id, name: tu.name, isError: !!res.isError });
        toolResultBlocks.push({
          type: 'tool_result',
          toolUseId: tu.id,
          content: res.output,
          isError: res.isError,
        });
      }
      if (toolResultBlocks.length > 0) {
        messages.push({ role: 'user', content: toolResultBlocks });
      }

      // Fold any user messages that arrived during this round into history so
      // the next stream pass sees them. This is what makes the conversation
      // feel alive: while the agent is grinding on a long task, a question
      // typed mid-stream lands in the very next request as a user-role part,
      // and the model can answer it inline before continuing.
      const interleaved = input.drainPending();
      if (interleaved.length > 0) {
        log.info(`[pi/session] interleaved ${interleaved.length} mid-turn user message(s) into history`);
        for (const m of interleaved) messages.push(m);
        pendingInterleave = true;
      } else {
        pendingInterleave = false;
      }

      // Exit when the model has nothing more to do AND no new user messages
      // arrived mid-round. Either condition by itself keeps the loop alive.
      if (toolUses.length === 0 && !pendingInterleave) break;
    }

    // Emit text_end only on a clean turn (don't persist a half-baked answer from an errored
    // turn). But ALWAYS emit turn_complete on a non-aborted turn — including the errored path
    // — so the supervisor clears agentQueryActive (set on turn_started). Skipping it on error
    // wedged the flag true: backend auto-heal stayed deferred and chat stuck in "typing" until
    // the next successful turn. The 'error' event was already emitted by runOneRound, so the
    // user still sees the failure. Aborted turns are torn down via bot:conversation-ended.
    if (!init.abortController.signal.aborted) {
      if (!turnErrored && accumulatedText) {
        init.onEvent({ type: 'text_end', text: accumulatedText });
      }
      const usedFileTools = Array.from(usedTools).some((t) => FILE_TOOL_NAMES.has(t));
      init.onEvent({ type: 'turn_complete', usedFileTools });
    }
  }

  return {
    async run(input) {
      for await (const userMsg of input) {
        if (init.abortController.signal.aborted) break;
        try {
          await runOneTurn(input, userMsg);
        } catch (err: any) {
          log.warn(`[pi/session] Turn failed: ${err?.message || err}`);
          init.onEvent({ type: 'error', error: err?.message || String(err) });
          // A thrown turn emitted no turn_complete either — clear agentQueryActive so auto-heal
          // and chat aren't wedged. Skip when aborting (teardown emits conversation-ended).
          // usedFileTools=false is the safe default (it only governs whether to auto-restart now).
          if (!init.abortController.signal.aborted) {
            init.onEvent({ type: 'turn_complete', usedFileTools: false });
          }
        }
      }
    },
    getMessages() {
      return messages;
    },
  };
}
