// frontend/src/lib/models/AgentChatModel.svelte.ts
// #region AgentChat.Model [C:4] [TYPE Model] [SEMANTICS agent-chat,model,gradio,langgraph]
// @defgroup AgentChat State model for Gradio-powered agent chat — submit() streaming, structured metadata, LangGraph HITL resume, no WebSocket.
// @INVARIANT Streaming state gates input: input disabled in streaming, awaiting_confirmation, disconnected states.
// @INVARIANT Connection state gates send: send rejected when connectionState !== "connected".
// @INVARIANT HITL resume: confirmation via second submit() with additional_inputs=[conversationId, "confirm"|"deny"]. Primary stream TERMINATES on confirm_required, second stream resumes from checkpoint.
// @INVARIANT Optimistic cancel: submission.cancel() stops generation, partial text preserved.
// @INVARIANT Conversation list loaded from FastAPI REST — Gradio serves only live agent streaming.
// @STATE idle | streaming | awaiting_confirmation | error | disconnected | disconnected_permanent
// @ACTION sendMessage(text, files?) — submit("/chat", {text, files}, [conversationId, null]), iterate stream events, process metadata.
// @ACTION cancelGeneration() — submission.cancel(), optimistic UI reset.
// @ACTION resumeConfirm(action) — second submit("/chat", {text:"confirm"}, [conversationId, action]) for HITL resume.
// @ACTION loadConversations(reset?) — fetch list from REST, infinite scroll.
// @ACTION loadHistory(conversationId?) — fetch messages from REST.
// @ACTION deleteConversation(id) — optimistic archive, rollback on failure.
// @ACTION createConversation() — clear state, start new.
// @ACTION retryConnection() — Client.connect() retry.
// @RELATION BINDS_TO -> [AssistantChatStore]
// @RELATION DEPENDS_ON -> [GradioClient:@gradio/client]
// @RELATION DEPENDS_ON -> [AssistantApi]
// @RATIONALE Model-first: extracted from AssistantChatPanel.svelte (1029 lines). Centralizes Gradio submit() lifecycle, metadata parsing, HITL resume, and reconnect logic in a testable unit. Without a model, streaming + metadata dispatch + confirmation + reconnect logic scattered across event handlers would be untestable.
// @REJECTED Inline $state in AssistantChatPanel.svelte — rejected because component already exceeds 400-line guideline.
// @REJECTED WebSocket-based model — rejected with custom WebSocket protocol.
// @REJECTED Active/follower multi-tab — rejected in favor of client-side gate.

import { Client, type Client as GradioClient } from "@gradio/client";
import {
  assistantChatStore,
  setAssistantConversationId,
} from "$lib/stores/assistantChat.svelte.js";
import {
  getAssistantConversations,
  getAssistantHistory,
  deleteAssistantConversation,
} from "$lib/api/assistant.js";
import { addToast } from "$lib/toasts.svelte.js";
import { log } from "$lib/cot-logger";

// ── Type definitions ─────────────────────────────────────────────

type StreamingState =
  | "idle"
  | "streaming"
  | "awaiting_confirmation"
  | "error"
  | "disconnected"
  | "disconnected_permanent";

type ConnectionState =
  | "connected"
  | "disconnected"
  | "disconnected_permanent";

interface StreamMetadata {
  type?: "stream_token" | "tool_start" | "tool_end" | "tool_error"
       | "confirm_required" | "confirm_resolved" | "error";
  token?: string;
  tool?: string;
  input?: Record<string, unknown>;
  output?: Record<string, unknown>;
  error?: string;
  prompt?: string;
  thread_id?: string;
  result?: "confirmed" | "denied";
  code?: string;
  detail?: string;
}

interface ToolCall {
  tool: string;
  input: Record<string, unknown>;
  output?: Record<string, unknown>;
  error?: string;
  status: "executing" | "completed" | "failed";
}

interface AgentMessage {
  id: string;
  conversation_id: string;
  role: "user" | "assistant" | "system" | "tool";
  text: string;
  metadata?: StreamMetadata;
  state?: string;
  task_id?: string;
  toolCalls: ToolCall[];
  created_at: string;
}

interface Conversation {
  id: string;
  title: string;
  updated_at: string;
  message_count: number;
}

// ── Reconnect constants ──────────────────────────────────────────

const RECONNECT_MAX_ATTEMPTS = 5;
const RECONNECT_INTERVAL_MS = 5_000;

export class AgentChatModel {
  // ── Atoms ──────────────────────────────────────────────────────
  messages: AgentMessage[] = $state([]);
  conversations: Conversation[] = $state([]);
  currentConversationId: string | null = $state(null);
  streamingState: StreamingState = $state("idle");
  connectionState: ConnectionState = $state("connected");
  error: string | null = $state(null);
  partialText: string = $state("");
  partialTokens: string[] = $state([]); // raw tokens for dedup
  activeToolCalls: ToolCall[] = $state([]);
  isLoadingHistory: boolean = $state(false);
  inputText: string = $state("");
  pendingThreadId: string | null = $state(null); // for HITL resume

  // ── Private atoms ──────────────────────────────────────────────
  private _client: GradioClient | null = null;
  private _submission: ReturnType<GradioClient["submit"]> | null = null;
  private _conversationsPage: number = 1;
  private _conversationsHasNext: boolean = $state(false);
  private _historyPage: number = 1;
  private _historyHasNext: boolean = $state(false);
  private _reconnectAttempts: number = 0;
  private _reconnectTimer: ReturnType<typeof setInterval> | null = null;
  private _messageQueue: Array<{ text: string; files?: File[] }> = $state([]);
  private _processingQueue: boolean = false;

  // ── Derived ────────────────────────────────────────────────────
  isStreaming = $derived(
    this.streamingState === "streaming" ||
      this.streamingState === "awaiting_confirmation",
  );
  isInputLocked = $derived(
    this.isStreaming ||
    this.streamingState === "disconnected" ||
    this.streamingState === "disconnected_permanent",
  );
  connectionDotColor = $derived(
    this.connectionState === "connected" ? "success"
      : this.connectionState === "disconnected" ? "warning"
      : "destructive",
  );
  queuePosition = $derived(this._messageQueue.length);
  conversationsHasNext = $derived(this._conversationsHasNext);

  // ── Actions — P1 ───────────────────────────────────────────────

  async sendMessage(text: string, files?: File[]): Promise<void> {
    if (!text.trim() && (!files || files.length === 0)) return;
    if (this.connectionState !== "connected") return;

    // If currently streaming, enqueue message
    if (this.streamingState !== "idle") {
      this._messageQueue = [...this._messageQueue, { text, files }];
      log("AgentChat.Model", "REASON", "Message queued", { queueSize: this._messageQueue.length });
      return;
    }

    await this._sendNow(text, files);
  }

  private async _sendNow(text: string, files?: File[]): Promise<void> {
    if (this._processingQueue) return; // prevent re-entry from queue drain
    const convId = this.currentConversationId;
    this.streamingState = "streaming";
    this.error = null;
    this.partialText = "";
    this.partialTokens = [];
    this.activeToolCalls = [];
    log("AgentChat.Model", "REASON", "Sending message", { text: text.slice(0, 100) });

    try {
      this._submission = this._client!.submit("chat",
        [{ text, files }, null, convId, null],
      );

      // Stream watcher: polls Client.stream_status.open. Когда SSE стрим закрыт
      // (close_stream → abort controller), дёргаем submission.return() чтобы
      // штатно завершить итератор. Если за 120с стрим не закрылся — абсолютный fallback.
      // Без этого for-await висит вечно: @gradio/client v1.19.1 close_stream()
      // не вызывает close() на итераторе submit().
      const streamDone = await Promise.race([
        this._processStream(this._submission, convId),
        this._streamCloseWatcher(120_000),
      ]);

      if (streamDone === false) {
        // SSE stream closed — принудительно завершаем итератор
        try { this._submission?.return?.(); } catch { /* ignore */ }
        this._submission = null;
      }
      this.streamingState = "idle";
      this._submission = null;
      this._persistMessages();

      // Drain queue after stream completes
      await this._drainQueue();
    } catch (e: unknown) {
      this.streamingState = "error";
      this.error = e instanceof Error ? e.message : "Stream failed";
      this._persistMessages();
      log("AgentChat.Model", "EXPLORE", "Stream failed", {}, this.error);
    }
  }

  private async _drainQueue(): Promise<void> {
    if (this._processingQueue) return;
    this._processingQueue = true;
    try {
      while (this._messageQueue.length > 0 && this.streamingState === "idle") {
        const next = this._messageQueue[0];
        this._messageQueue = this._messageQueue.slice(1);
        log("AgentChat.Model", "REASON", "Processing queued message", { remaining: this._messageQueue.length });
        await this._sendNow(next.text, next.files);
      }
    } finally {
      this._processingQueue = false;
    }
  }

  cancelGeneration(): void {
    if (this.streamingState === "idle") return;
    this._submission?.cancel();
    this.streamingState = "idle";
    this._submission = null;
    log("AgentChat.Model", "REASON", "Generation cancelled");
  }

  async resumeConfirm(action: "confirm" | "deny"): Promise<void> {
    if (this.streamingState !== "awaiting_confirmation") return;
    if (!this.pendingThreadId || !this.currentConversationId) return;

    log("AgentChat.Model", "REASON", `HITL resume: ${action}`, { threadId: this.pendingThreadId });

    try {
      const submission = this._client!.submit("chat",
        [{ text: action === "confirm" ? "confirm" : "deny", files: null }, null, this.currentConversationId, action],
      );

      for await (const event of submission) {
        const msg: AgentMessage = event.data;
        this._onStreamData(msg);
      }
      this.streamingState = "idle";
      this.pendingThreadId = null;
      this._persistMessages();

    } catch (e: unknown) {
      this.streamingState = "error";
      this.error = e instanceof Error ? e.message : "Resume failed";
      log("AgentChat.Model", "EXPLORE", "HITL resume failed", {}, this.error);
    }
  }

  async loadConversations(reset: boolean = false): Promise<void> {
    log("AgentChat.Model", "REASON", "Loading conversations", { reset });
    try {
      if (reset) {
        this._conversationsPage = 1;
      }
      const res = await getAssistantConversations(
        this._conversationsPage, 20, false, ""
      );
      const items = res.items || [];
      // API возвращает conversation_id — нормализуем в id для единообразия
      this.conversations = reset
        ? items.map((item: Record<string, unknown>) => ({ id: item.conversation_id ?? item.id ?? "", title: item.title ?? "", updated_at: item.updated_at ?? "", message_count: item.message_count ?? 0 }))
        : [...this.conversations, ...items.map((item: Record<string, unknown>) => ({ id: item.conversation_id ?? item.id ?? "", title: item.title ?? "", updated_at: item.updated_at ?? "", message_count: item.message_count ?? 0 }))];
      this._conversationsHasNext = Boolean(res.has_next);
      this._conversationsPage++;
    } catch (e: unknown) {
      this.error = e instanceof Error ? e.message : "Failed to load conversations";
      log("AgentChat.Model", "EXPLORE", "Load conversations failed", {}, this.error);
    }
  }

  async searchConversations(query: string): Promise<void> {
    log("AgentChat.Model", "REASON", "Searching conversations", { query });
    try {
      this._conversationsPage = 1;
      const res = await getAssistantConversations(
        1, 20, false, query
      );
      const items = res.items || [];
      this.conversations = items.map((item: Record<string, unknown>) => ({
        id: item.conversation_id ?? item.id ?? "",
        title: item.title ?? "",
        updated_at: item.updated_at ?? "",
        message_count: item.message_count ?? 0,
      }));
      this._conversationsHasNext = Boolean(res.has_next);
      this._conversationsPage++;
    } catch (e: unknown) {
      this.error = e instanceof Error ? e.message : "Failed to search conversations";
      log("AgentChat.Model", "EXPLORE", "Search conversations failed", {}, this.error);
    }
  }

  async loadHistory(conversationId: string | null = null): Promise<void> {
    this.isLoadingHistory = true;
    this.error = null;
    log("AgentChat.Model", "REASON", "Loading history", { conversationId });
    try {
      const targetId = conversationId ?? this.currentConversationId;
      if (!targetId) {
        this.isLoadingHistory = false;
        return;
      }

      // Try localStorage first for offline/fallback
      if (this._loadFromLocalStorage(targetId)) {
        this.isLoadingHistory = false;
        return;
      }

      const res = await getAssistantHistory(1, 30, targetId);
      const items = res.items || [];
      this.messages = items.map((msg: Record<string, unknown>) => ({
        id: (msg.message_id as string) ?? msg.id as string ?? "",
        conversation_id: msg.conversation_id as string ?? "",
        role: msg.role as string ?? "assistant",
        text: msg.text as string ?? "",
        metadata: msg.metadata as StreamMetadata,
        toolCalls: msg.tool_calls as ToolCall[] ?? [],
        created_at: msg.created_at as string ?? "",
      }));
      if (res.conversation_id && !this.currentConversationId) {
        setAssistantConversationId(res.conversation_id);
        this.currentConversationId = res.conversation_id;
      }
    } catch (e: unknown) {
      this.error = e instanceof Error ? e.message : "Failed to load history";
      log("AgentChat.Model", "EXPLORE", "Load history failed", {}, this.error);
    } finally {
      this.isLoadingHistory = false;
    }
  }

  async retryConnection(): Promise<void> {
    log("AgentChat.Model", "REASON", "Manual reconnect");
    this._reconnectAttempts = 0;
    try {
      this._client = await Client.connect("/api/agent/gradio");
      this.connectionState = "connected";
      this.streamingState = "idle";
      log("AgentChat.Model", "REFLECT", "Reconnected successfully");
    } catch (e: unknown) {
      this.connectionState = "disconnected_permanent";
      log("AgentChat.Model", "EXPLORE", "Reconnect failed", {}, e instanceof Error ? e.message : "Unknown");
    }
  }

  createConversation(): void {
    // Save current conversation before clearing
    if (this.currentConversationId && this.messages.length > 0) {
      this._saveToLocalStorage();
    }
    this.messages = [];
    this.currentConversationId = null;
    this.streamingState = "idle";
    this.error = null;
    this.partialText = "";
    this.activeToolCalls = [];
    this.pendingThreadId = null;
    this._historyPage = 1;
    this._historyHasNext = false;
    log("AgentChat.Model", "REASON", "New conversation created");
  }

  // ── Actions — P2 ───────────────────────────────────────────────

  async deleteConversation(id: string): Promise<void> {
    // Optimistic removal
    const prevConversations = [...this.conversations];
    this.conversations = this.conversations.filter((c) => c.id !== id);
    this._clearLocalStorage(id);
    log("AgentChat.Model", "REASON", "Archiving conversation (optimistic)", { id });
    try {
      await deleteAssistantConversation(id);
      addToast("Диалог архивирован", "success");
      if (this.currentConversationId === id) {
        this.createConversation();
      }
    } catch (e: unknown) {
      // Rollback
      this.conversations = prevConversations;
      this.error = e instanceof Error ? e.message : "Failed to archive conversation";
      addToast("Не удалось архивировать диалог", "error");
      log("AgentChat.Model", "EXPLORE", "Archive failed, rolled back", {}, this.error);
    }
  }

  // ── Private — Stream metadata handler ──────────────────────────

  /** Iterate Gradio submit() events and update model state. Returns true when stream completes. */
  private async _processStream(submission: ReturnType<GradioClient["submit"]>, convId: string | null): Promise<true> {
    for await (const event of submission) {
      if (event.type === "heartbeat") continue;

      const raw = event.data;
      let parsed: AgentMessage;

      if (Array.isArray(raw)) {
        const jsonStr = raw[0];
        if (typeof jsonStr === "string") {
          try {
            const obj = JSON.parse(jsonStr);
            parsed = { id: "", conversation_id: convId ?? "", role: "assistant", text: obj.content ?? jsonStr, metadata: obj.metadata, toolCalls: [], created_at: "" };
          } catch {
            parsed = { id: "", conversation_id: "", role: "assistant", text: jsonStr, toolCalls: [], created_at: "" };
          }
        } else {
          parsed = { id: "", conversation_id: "", role: "assistant", text: jsonStr?.text ?? "", metadata: jsonStr?.metadata, toolCalls: [], created_at: "" };
        }
      } else if (typeof raw === "string") {
        try {
          const obj = JSON.parse(raw);
          parsed = { id: "", conversation_id: convId ?? "", role: "assistant", text: obj.content ?? raw, metadata: obj.metadata, toolCalls: [], created_at: "" };
        } catch {
          parsed = { id: "", conversation_id: "", role: "assistant", text: raw, toolCalls: [], created_at: "" };
        }
      } else {
        const obj = raw as Record<string, unknown>;
        parsed = { id: "", conversation_id: convId ?? "", role: "assistant", text: (obj.content as string) ?? obj.text as string ?? "", metadata: obj.metadata as StreamMetadata, toolCalls: [], created_at: "" };
      }

      this._onStreamData(parsed);
    }
    return true;
  }

  /** Watch Client.stream_status.open — when SSE stream closes via close_stream(),
   *  return false so Promise.race terminates the iterator via .return().
   *
   *  stream_status изначально { open: false }. Ждём сначала open=true (SSE открыт),
   *  потом open=false (SSE закрыт). Без этого шага watcher срабатывает мгновенно
   *  на исходном false. */
  private async _streamCloseWatcher(maxWaitMs: number): Promise<false> {
    const deadline = Date.now() + maxWaitMs;
    let wasOpen = false;
    while (Date.now() < deadline) {
      const ss = (this._client as any)?.stream_status;
      if (ss) {
        if (!wasOpen && ss.open === true) wasOpen = true;
        if (wasOpen && ss.open === false) return false;
      }
      await new Promise((r) => setTimeout(r, 100));
    }
    return false;
  }

  private _captureConversationId(meta: StreamMetadata | undefined, convIdFromEvent: string): void {
    // Extract conversation_id from stream metadata or event data
    // Gradio backend returns thread_id (LangGraph thread) = conversation_id
    const newId = meta?.thread_id || convIdFromEvent || null;
    if (newId && !this.currentConversationId) {
      this.currentConversationId = newId;
      if (this.currentConversationId) {
        setAssistantConversationId(this.currentConversationId);
      }
      this._saveToLocalStorage();
    }
  }

  private _onStreamData(msg: AgentMessage): void {
    const meta = msg.metadata;

    // Capture conversation_id if present
    this._captureConversationId(meta, msg.conversation_id);

    if (!meta || !meta.type) {
      // Plain text token — append to last assistant message
      this.partialText += msg.text;
      return;
    }

    switch (meta.type) {
      case "stream_token": {
        const token = meta.token ?? "";
        // Dedup: skip if token is already at the end of accumulated text
        // (Grady's final "data" event carries the complete text)
        if (token && this.partialText.endsWith(token)) break;
        this.partialText += token;
        this.partialTokens = [...this.partialTokens, token];
        break;
      }

      case "tool_start":
        this.activeToolCalls = [...this.activeToolCalls, {
          tool: meta.tool ?? "unknown",
          input: meta.input ?? {},
          status: "executing",
        }];
        break;

      case "tool_end":
        this.activeToolCalls = this.activeToolCalls.map((tc) =>
          tc.tool === meta.tool
            ? { ...tc, output: meta.output, status: "completed" }
            : tc
        );
        break;

      case "tool_error":
        this.activeToolCalls = this.activeToolCalls.map((tc) =>
          tc.tool === meta.tool
            ? { ...tc, error: meta.error, status: "failed" }
            : tc
        );
        break;

      case "confirm_required":
        this.streamingState = "awaiting_confirmation";
        this.pendingThreadId = meta.thread_id ?? null;
        // Primary stream terminates — model enters awaiting_confirmation
        // Second submit() will call resumeConfirm()
        break;

      case "confirm_resolved":
        this.streamingState = meta.result === "confirmed" ? "streaming" : "idle";
        this.pendingThreadId = null;
        break;

      case "error":
        this.streamingState = "error";
        this.error = meta.detail ?? meta.code ?? "Unknown error";
        break;

      default:
        log("AgentChat.Model", "EXPLORE", "Unknown metadata type", { type: meta.type });
    }
  }

  // ── Private — localStorage persistence ─────────────────────────

  private readonly STORAGE_PREFIX = "agentchat:";
  private readonly CONVERSATIONS_KEY = "agentchat:conversations";

  private _saveToLocalStorage(): void {
    try {
      if (this.currentConversationId) {
        const key = `${this.STORAGE_PREFIX}messages:${this.currentConversationId}`;
        const data = {
          messages: this.messages,
          partialText: this.partialText,
          conversationId: this.currentConversationId,
        };
        localStorage.setItem(key, JSON.stringify(data));
      }
    } catch {
      // localStorage may be full or unavailable — silently ignore
    }
  }

  private _loadFromLocalStorage(conversationId: string): boolean {
    try {
      const key = `${this.STORAGE_PREFIX}messages:${conversationId}`;
      const raw = localStorage.getItem(key);
      if (!raw) return false;
      const data = JSON.parse(raw);
      if (data?.messages && Array.isArray(data.messages)) {
        this.messages = data.messages;
        this.currentConversationId = data.conversationId || conversationId;
        return true;
      }
      return false;
    } catch {
      return false;
    }
  }

  private _clearLocalStorage(conversationId: string): void {
    try {
      const key = `${this.STORAGE_PREFIX}messages:${conversationId}`;
      localStorage.removeItem(key);
    } catch {
      // silent
    }
  }

  /** Save messages after streaming completes */
  private _persistMessages(): void {
    if (this.currentConversationId) {
      this._saveToLocalStorage();
    }
  }

  // ── Private — Connection lifecycle ─────────────────────────────

  private _onDisconnect(): void {
    this.connectionState = "disconnected";
    this._reconnectAttempts = 0;
    log("AgentChat.Model", "EXPLORE", "Gradio disconnected");
    this._startReconnectLoop();
  }

  private _onReconnect(): void {
    this.connectionState = "connected";
    this.streamingState = "idle";
    this._reconnectAttempts = 0;
    if (this._reconnectTimer) {
      clearInterval(this._reconnectTimer);
      this._reconnectTimer = null;
    }
    log("AgentChat.Model", "REFLECT", "Gradio reconnected");
  }

  private _startReconnectLoop(): void {
    if (this._reconnectTimer) return;
    this._reconnectTimer = setInterval(async () => {
      this._reconnectAttempts++;
      if (this._reconnectAttempts > RECONNECT_MAX_ATTEMPTS) {
        this.connectionState = "disconnected_permanent";
        if (this._reconnectTimer) {
          clearInterval(this._reconnectTimer);
          this._reconnectTimer = null;
        }
        log("AgentChat.Model", "EXPLORE", "Max reconnect attempts reached", { attempts: this._reconnectAttempts });
        return;
      }
      try {
        this._client = await Client.connect("/api/agent/gradio");
        this._onReconnect();
      } catch {
        log("AgentChat.Model", "EXPLORE", "Reconnect attempt failed", { attempt: this._reconnectAttempts });
      }
    }, RECONNECT_INTERVAL_MS);
  }
}
// #endregion AgentChat.Model
