import { Injectable, computed, inject, signal } from '@angular/core';
import { ChatbotApiError, ChatbotApiService } from './chatbot-api.service';
import type {
  ChatbotMessage,
  ChatbotView,
  CitationFramePayload,
  CitationRef,
  DoneFramePayload,
  ErrorFramePayload,
  TokenFramePayload,
} from './chatbot.types';

/**
 * Signal-based state for the chatbot widget. Owns the conversation
 * buffer, the open/fullscreen toggle, and the in-flight
 * `AbortController` for the active stream.
 *
 * Conversation persistence is intentionally **session-ephemeral**
 * per the design decision recorded with PR #197 — a page reload
 * starts a fresh conversation. Matches `apf-ai-service`'s
 * server-side posture (no per-user conversation table in v1) and
 * avoids the privacy question of storing free-form transcripts in
 * `localStorage` before a proper consent flow lands.
 *
 * The service is `providedIn: 'root'` so the open/close state
 * survives navigations within the SPA — the user expects the widget
 * state to be stable across the audit page, dashboard, etc.
 */
@Injectable({ providedIn: 'root' })
export class ChatbotService {
  private readonly api = inject(ChatbotApiService);

  // ---- private mutable state ------------------------------

  private readonly _view = signal<ChatbotView>('closed');
  private readonly _messages = signal<readonly ChatbotMessage[]>([]);
  private readonly _streamingMessageId = signal<string | null>(null);
  private readonly _selectedCitation = signal<CitationRef | null>(null);
  /** Active cancellation token for the in-flight stream, if any. */
  private currentAbort: AbortController | null = null;

  // ---- public read-only surface ---------------------------

  readonly view = this._view.asReadonly();
  readonly messages = this._messages.asReadonly();
  readonly selectedCitation = this._selectedCitation.asReadonly();
  readonly isOpen = computed(() => this._view() !== 'closed');
  readonly isFullscreen = computed(() => this._view() === 'fullscreen');
  readonly isStreaming = computed(() => this._streamingMessageId() !== null);
  /**
   * Empty when no user message has landed yet. Drives the greeting
   * + suggestion grid render in the empty state.
   */
  readonly isEmpty = computed(() => this._messages().length === 0);

  // ---- view-state actions ---------------------------------

  open(view: 'windowed' | 'fullscreen' = 'windowed'): void {
    this._view.set(view);
  }

  close(): void {
    this._view.set('closed');
    this._selectedCitation.set(null);
    this.stop();
  }

  toggleFullscreen(): void {
    this._view.update((v) => (v === 'fullscreen' ? 'windowed' : 'fullscreen'));
  }

  selectCitation(citation: CitationRef | null): void {
    this._selectedCitation.set(citation);
  }

  /**
   * Cancel the in-flight stream (if any). The streaming message is
   * marked as `done` so the UI surfaces a stable terminal state
   * rather than an animated "still typing" indicator.
   */
  stop(): void {
    if (this.currentAbort === null) {
      return;
    }
    this.currentAbort.abort();
    this.currentAbort = null;
    const streamingId = this._streamingMessageId();
    if (streamingId !== null) {
      this.markStreamingDone(streamingId);
    }
  }

  // ---- chat flow ------------------------------------------

  /**
   * Append a user message, open the panel if it is closed, and
   * stream the assistant's reply. Resolves when the stream ends
   * (success, error, or cancellation). Errors land on the streaming
   * message as an inline alert — they do not throw to the caller.
   */
  async send(prompt: string): Promise<void> {
    const trimmed = prompt.trim();
    if (trimmed === '' || this.isStreaming()) {
      return;
    }
    if (!this.isOpen()) {
      this.open();
    }

    const userMessageId = createMessageId();
    const assistantMessageId = createMessageId();

    // Append both messages in one update so the UI does not flicker
    // through a "user message but no placeholder reply" frame.
    this._messages.update((existing) => [
      ...existing,
      {
        id: userMessageId,
        role: 'user',
        content: trimmed,
        citations: [],
        done: true,
      },
      {
        id: assistantMessageId,
        role: 'assistant',
        content: '',
        citations: [],
        done: false,
      },
    ]);
    this._streamingMessageId.set(assistantMessageId);

    const abort = new AbortController();
    this.currentAbort = abort;

    try {
      const history = this.snapshotHistoryForRequest();
      const stream = await this.api.openChatStream({ messages: history }, abort.signal);

      let citationCounter = 0;
      for await (const frame of stream) {
        if (abort.signal.aborted) {
          break;
        }
        switch (frame.event) {
          case 'token': {
            const payload = frame.data as TokenFramePayload | undefined;
            const tokenValue = payload?.value ?? payload?.token ?? '';
            if (tokenValue !== '') {
              this.appendContent(assistantMessageId, tokenValue);
            }
            break;
          }
          case 'citation': {
            const payload = frame.data as CitationFramePayload | undefined;
            if (payload !== undefined) {
              citationCounter += 1;
              this.appendCitation(assistantMessageId, {
                index: citationCounter,
                chunkId: payload.chunkId,
                documentId: payload.documentId,
                source: payload.source,
                score: payload.score,
                snippet: payload.snippet,
              });
            }
            break;
          }
          case 'error': {
            const payload = frame.data as ErrorFramePayload | undefined;
            this.markStreamingError(
              assistantMessageId,
              payload?.code ?? 'urn:apf-ai:relay_error',
              payload?.message ?? 'The assistant could not finish answering.',
            );
            return;
          }
          case 'done': {
            // The done frame is informational (stats). The terminal
            // marker on the message is set in the finally block so
            // the bookkeeping stays in one place.
            const _payload = frame.data as DoneFramePayload | undefined;
            void _payload;
            return;
          }
          // tool-call and agent-step are not rendered in v1 — the
          // SSE writer still emits them so the parser does not log
          // unrecognised events. Falling through is intentional.
          default:
            break;
        }
      }
    } catch (err) {
      if (abort.signal.aborted) {
        // The cancellation path runs `stop()`, which already marks
        // the message done.
        return;
      }
      const apiError = err instanceof ChatbotApiError ? err : null;
      this.markStreamingError(
        assistantMessageId,
        apiError?.code ?? 'urn:apf-portal:chatbot_relay_error',
        apiError?.message ?? 'Could not reach the assistant. Try again in a moment.',
      );
    } finally {
      if (this.currentAbort === abort) {
        this.currentAbort = null;
      }
      this.markStreamingDone(assistantMessageId);
    }
  }

  // ---- private buffer mutations ---------------------------

  private appendContent(messageId: string, fragment: string): void {
    this._messages.update((existing) =>
      existing.map((m) => (m.id === messageId ? { ...m, content: m.content + fragment } : m)),
    );
  }

  private appendCitation(messageId: string, citation: CitationRef): void {
    this._messages.update((existing) =>
      existing.map((m) =>
        m.id === messageId ? { ...m, citations: [...m.citations, citation] } : m,
      ),
    );
  }

  private markStreamingError(messageId: string, code: string, message: string): void {
    this._messages.update((existing) =>
      existing.map((m) =>
        m.id === messageId ? { ...m, errorCode: code, errorMessage: message, done: true } : m,
      ),
    );
  }

  private markStreamingDone(messageId: string): void {
    this._messages.update((existing) =>
      existing.map((m) => (m.id === messageId && !m.done ? { ...m, done: true } : m)),
    );
    if (this._streamingMessageId() === messageId) {
      this._streamingMessageId.set(null);
    }
  }

  /**
   * Build the request payload from the visible history, excluding
   * the empty placeholder the service just appended (the last
   * assistant message is still pending). System / error messages
   * are not surfaced to the AI service — only the back-and-forth.
   */
  private snapshotHistoryForRequest(): ReadonlyArray<{
    role: 'user' | 'assistant' | 'system';
    content: string;
  }> {
    return this._messages()
      .filter((m) => m.content !== '' && m.errorCode === undefined)
      .map((m) => ({ role: m.role, content: m.content }));
  }
}

let nextId = 1;
function createMessageId(): string {
  // Deterministic-enough for `track` in the template. Not used for
  // anything user-visible; the BFF rebuilds its own correlation id
  // for audit purposes.
  return `m-${Date.now().toString(36)}-${(nextId++).toString(36)}`;
}
