// 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.
// @INVARIANT DECOMPOSITION GATE (630→~340 lines): Stream processing → AgentChat.StreamProcessor; Connection lifecycle → AgentChat.ConnectionManager; Persistence → AgentChat.LocalStorage; Types → AgentChatTypes.
// @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() — delegates to ConnectionManager.retryConnection().
// @RELATION BINDS_TO -> [AssistantChatStore]
// @RELATION DEPENDS_ON -> [AgentChat.ConnectionManager]
// @RELATION DEPENDS_ON -> [AgentChat.StreamProcessor]
// @RELATION DEPENDS_ON -> [AgentChat.LocalStorage]
// @RELATION DEPENDS_ON -> [AgentChatTypes]
// @RATIONALE Model-first: extracted from AssistantChatPanel.svelte (1029 lines). Decomposed further per DECOMPOSITION GATE into sub-helpers (ConnectionManager, StreamProcessor, LocalStorage) to stay under 400-line limit.
// @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 { 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";
import { type ConnectionManagerCallbacks, ConnectionManager } from "./AgentChat.ConnectionManager.svelte.js";
import { type StreamProcessorHost, StreamProcessor } from "./AgentChat.StreamProcessor.svelte.js";
import { LocalStorageManager } from "./AgentChat.LocalStorage.js";
import type {
  StreamingState, ConnectionState, StreamMetadata, ToolCall, AgentMessage, Conversation,
} from "./AgentChatTypes.js";

// ═══ Main model ═════════════════════════════════════════════════

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([]);
  activeToolCalls: ToolCall[] = $state([]);
  isLoadingHistory: boolean = $state(false);
  inputText: string = $state("");
  pendingThreadId: string | null = $state(null);

  // ── Private fields ─────────────────────────────────────────────
  _client: GradioClient | null = null; // non-private for legacy Object.assign usage
  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 _messageQueue: Array<{ text: string; files?: File[] }> = $state([]);
  private _processingQueue: boolean = false;

  // ── Sub-helpers ────────────────────────────────────────────────
  readonly connection: ConnectionManager;
  private readonly streamProcessor: StreamProcessor;
  private readonly storage: LocalStorageManager;

  // ── 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);

  constructor() {
    const connectionCbs: ConnectionManagerCallbacks = {
      onConnected: (client) => { this._client = client; this.connectionState = "connected"; },
      onDisconnected: () => { this.connectionState = "disconnected"; },
      onDisconnectedPermanent: () => { this.connectionState = "disconnected_permanent"; },
      onStreamingStateChange: (s) => { this.streamingState = s; },
    };
    this.connection = new ConnectionManager(connectionCbs);
    this.streamProcessor = new StreamProcessor(this as unknown as StreamProcessorHost);
    this.storage = new LocalStorageManager();
  }

  // ── Actions — P1 (streaming) ───────────────────────────────────

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

    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;
    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. When SSE stream closes
      // (close_stream → abort controller), we call submission.return() to
      // terminate the iterator. If stream doesn't close in 120s — absolute fallback.
      const streamDone = await Promise.race([
        this.streamProcessor.processStream(this._submission, convId),
        this.streamProcessor.streamCloseWatcher(this._client, 120_000),
      ]);

      if (streamDone === false) {
        try { this._submission?.return?.(); } catch { /* ignore */ }
      }
      this.streamingState = "idle";
      this._submission = null;
      this._persistMessages();
      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],
      );

      await this.streamProcessor.processStream(submission, this.currentConversationId);
      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);
    }
  }

  // ── Actions — P2 (data + lifecycle) ────────────────────────────

  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 || [];
      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);
      this.conversations = (res.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; }

      if (this._loadFromStorage(targetId)) { this.isLoadingHistory = false; return; }

      const res = await getAssistantHistory(1, 30, targetId);
      this.messages = (res.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 as string);
        this.currentConversationId = res.conversation_id as string;
      }
    } 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> {
    return this.connection.retryConnection();
  }

  createConversation(): void {
    if (this.currentConversationId && this.messages.length > 0) {
      this._saveToStorage();
    }
    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");
  }

  async deleteConversation(id: string): Promise<void> {
    const prevConversations = [...this.conversations];
    this.conversations = this.conversations.filter((c) => c.id !== id);
    this.storage.clear(id);
    log("AgentChat.Model", "REASON", "Archiving conversation (optimistic)", { id });
    try {
      await deleteAssistantConversation(id);
      addToast("Диалог архивирован", "success");
      if (this.currentConversationId === id) this.createConversation();
    } catch (e: unknown) {
      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);
    }
  }

  /** Extract conversation_id from stream metadata or event data */
  _captureConversationId(meta: StreamMetadata | undefined, convIdFromEvent: string): void {
    this.captureConversationId(meta, convIdFromEvent);
  }

  /** Public alias for StreamProcessorHost interface contract */
  captureConversationId(meta: StreamMetadata | undefined, convIdFromEvent: string): void {
    const newId = meta?.thread_id || convIdFromEvent || null;
    if (newId && !this.currentConversationId) {
      this.currentConversationId = newId;
      if (this.currentConversationId) setAssistantConversationId(this.currentConversationId);
      this._saveToStorage();
    }
  }

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

  // ── Private — storage helpers ──────────────────────────────────

  private _saveToStorage(): void {
    if (this.currentConversationId) {
      this.storage.save(this.currentConversationId, this.messages, this.partialText);
    }
  }

  private _loadFromStorage(conversationId: string): boolean {
    const data = this.storage.load(conversationId);
    if (data) {
      this.messages = data.messages;
      this.currentConversationId = data.conversationId;
      return true;
    }
    return false;
  }
}
// #endregion AgentChat.Model
