import type {
  ChatMessage,
  Conversation,
  ConversationSummary,
  MessagePart,
  MessageRole,
} from '@/features/loyaly-ai/types/chat';

/**
 * Domain rules for Loyaly AI.
 *
 * Framework-free: no React, no Astryx. Everything here is about what a
 * conversation IS — how a message is built, how a chat gets its title, what
 * order history appears in — and none of it should have to change when the
 * surface is redesigned or the transport is swapped.
 */

/**
 * Ids are generated, and deliberately not with Math.random() or Date.now()
 * during render. A monotonic counter seeded per module gives stable,
 * collision-free ids without touching either.
 */
let sequence = 0;
function nextId(prefix: string): string {
  sequence += 1;
  return `${prefix}_${sequence}`;
}

export function createMessage(
  role: MessageRole,
  parts: MessagePart[],
  options?: {isStreaming?: boolean},
): ChatMessage {
  return {
    id: nextId(role === 'user' ? 'usr' : 'ast'),
    role,
    parts,
    // Stamped on a user event, never during render — Date.now() in a render
    // path is a hydration mismatch waiting to happen.
    at: new Date().toISOString(),
    isStreaming: options?.isStreaming,
  };
}

export function createConversation(): Conversation {
  const now = new Date().toISOString();
  return {
    id: nextId('conv'),
    title: 'New chat',
    messages: [],
    createdAt: now,
    updatedAt: now,
  };
}

/** The longest prefix of a title that reads as a phrase rather than a truncation. */
const TITLE_MAX = 48;

/**
 * Name a conversation from its first user message.
 *
 * Cut on a word boundary rather than mid-word: "Compare stores by conve…" is
 * a title, "Compare stores by conv" is a bug. Falls back to the raw slice only
 * when the first word is itself longer than the budget.
 */
export function titleFromPrompt(prompt: string): string {
  const clean = prompt.trim().replace(/\s+/g, ' ');
  if (!clean) return 'New chat';
  if (clean.length <= TITLE_MAX) return clean;

  const cut = clean.slice(0, TITLE_MAX);
  const lastSpace = cut.lastIndexOf(' ');
  return `${lastSpace > 16 ? cut.slice(0, lastSpace) : cut}…`;
}

/**
 * History, most recently touched first, with empty conversations omitted.
 *
 * A chat you opened and never used is not history — listing it means "New
 * chat" accumulates in the drawer every time someone clicks the button.
 */
export function toHistory(conversations: Conversation[]): ConversationSummary[] {
  return conversations
    .filter((c) => c.messages.length > 0)
    .slice()
    .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
    .map((c) => ({
      id: c.id,
      title: c.title,
      updatedAt: c.updatedAt,
      messageCount: c.messages.length,
    }));
}

/**
 * Apply a streamed part to the assistant message being written.
 *
 * Text REPLACES the last text part (the repository yields cumulative text, so
 * a dropped chunk cannot corrupt the message); anything else appends, because
 * a chart and a table are separate blocks rather than revisions of one.
 */
export function applyStreamedPart(
  message: ChatMessage,
  part: MessagePart,
): ChatMessage {
  const parts = [...message.parts];
  const lastIndex = parts.length - 1;

  if (lastIndex >= 0 && parts[lastIndex].type === part.type) {
    parts[lastIndex] = part;
  } else {
    parts.push(part);
  }
  return {...message, parts};
}
