/**
 * AI Response System for BaoLife Conversations
 * Ported from Python conversationEvents.py
 *
 * Handles:
 * - Multiple AI provider support (OpenAI, Together AI, Mistral, OpenRouter)
 * - Verbosity detection for natural response lengths
 * - Fallback responses when API unavailable
 * - Conversation context management
 * - Typing indicators
 */

import OpenAI from 'openai';
import { config } from '../../config.js';
import { ConversationObj, ConversationMessage } from './types.js';
import { Player, Person } from '../../models/index.js';
import {
  generateOpenAIDescription,
  generatePersonDescription,
} from '../../services/character/character_manager.js';
import { checkRateLimit } from '../../monitoring/rate_limiter.js';
import { apiUsageTracker } from '../../monitoring/api_usage_tracker.js';
import { CharacterMemory } from './character_memory.js';
import { getMessagingStylePrompt, getRelationshipData, ensureRelationshipData, updateConversationMessagingModifiers } from './messagingStyle.js';
// Tool calling imports
import {
  conversationTools,
  getAvailableTools,
  canUseTool,
  recordToolUse,
  toolMetadata,
} from './tools.js';
import {
  processToolCall,
  ToolCallResult,
  PendingConversationEvent,
} from './tool_processor.js';
import { addPendingEvent } from './pending_events.js';
import { recordPositiveInteraction } from '../../services/relationships/relationship_manager.js';

/**
 * Estimate token count for a string.
 * Uses a character-based heuristic (~4 chars per token for English text).
 * Accurate enough for context window management without needing tiktoken.
 */
function estimateTokens(text: string): number {
  if (!text) return 0;
  return Math.ceil(text.length / 3.5); // Slightly conservative estimate
}

/**
 * Estimate total tokens for a message list (system + conversation messages).
 */
function estimateMessageListTokens(
  messages: Array<{ role: string; content: string }>
): number {
  let total = 0;
  for (const msg of messages) {
    total += estimateTokens(msg.content);
    total += 4; // Overhead per message (role, delimiters)
  }
  return total;
}

/**
 * Get the thinking buffer proportional to the response budget.
 * Smaller responses get a smaller buffer so thinking doesn't dominate.
 * Larger responses get more room for reasoning.
 */
function getThinkingBuffer(maxTokens: number): number {
  return Math.min(1024, Math.floor(maxTokens * 0.5));
}

/**
 * Maximum input tokens before we start trimming context.
 * Most open models support 32K-128K; we target a safe budget
 * leaving room for the response.
 */
const MAX_INPUT_TOKENS = 24000;

/**
 * Estimate token cost of tool definitions sent to the API.
 * Tool schemas (function name, description, parameters) consume input tokens.
 */
function estimateToolTokens(tools: Array<{ type: string; function: { name: string; description: string; parameters: Record<string, unknown> } }>): number {
  if (!tools || tools.length === 0) return 0;
  // Estimate by serializing the tool definitions to JSON and counting chars
  const serialized = JSON.stringify(tools);
  return estimateTokens(serialized);
}

/**
 * JSON Schema for structured AI conversation responses
 * Used with Together AI's response_format for guaranteed structure
 */
const conversationResponseSchema = {
  type: "object" as const,
  properties: {
    message: {
      type: "string" as const,
      description: "Your conversational response to the player"
    },
    sentiment: {
      type: "string" as const,
      enum: ["positive", "negative", "neutral"],
      description: "How this interaction affects your opinion of the player"
    },
    affinityDelta: {
      type: "integer" as const,
      description: "How much this message changes your opinion of the player, from -50 (hostile/cruel) to +30 (deeply meaningful). Score from YOUR perspective given your current relationship. Examples: casual 'lol' = +1, sharing a story = +5, heartfelt confession from partner = +20, 'I hate you' = -40, unwanted 'I love you' from stranger = -15"
    }
  },
  required: ["message", "sentiment", "affinityDelta"],
  additionalProperties: false
};

const AFFINITY_SCORING_INSTRUCTIONS = `
You MUST respond with a JSON object containing three fields:
- "message": your conversational response (the text to show)
- "sentiment": overall tone ("positive", "negative", or "neutral")
- "affinityDelta": integer from -50 to +30 showing how this message changes your opinion of the player

AFFINITY SCORING GUIDE (score from YOUR perspective, given your current relationship):
+1 to +3: Casual positive ("lol", "nice", basic engagement)
+3 to +8: Good conversation (sharing stories, asking about your day)
+5 to +15: Deep/vulnerable sharing (opening up about fears, dreams, feelings)
+8 to +20: Meaningful compliments, genuine affection
+15 to +30: Deeply significant romantic moments (ONLY if you're in a committed relationship and you feel the same way)
-3 to -8: Mild rudeness (dismissive, ignoring your question, "whatever")
-15 to -30: Hurtful/dismissive (insulting you, saying they don't care about you)
-30 to -50: Hostile/cruel (personal attacks, "I hate you", betrayal)
-20 to -40: Inappropriate behavior (unwanted romantic advances, crossing boundaries)

CRITICAL: Score based on how YOU would actually feel receiving this message. Context matters enormously:
- "I love you" from your committed partner who treats you well = +20
- "I love you" from someone you barely know = -15 (uncomfortable/creepy)
- An apology after a fight should be worth a lot MORE if the fight was bad
- Ignoring a serious question hurts MORE from someone close to you
- Don't give +0 for everything — have real opinions and reactions
`;

// ============================================================
// Tool Calling Configuration
// ============================================================

/**
 * Configuration for when to use tool calling vs JSON schema
 */
interface ToolCallingConfig {
  /** Enable tool calling feature */
  enabled: boolean;
  /** Minimum affinity to enable rich tools */
  minAffinityForRichTools: number;
  /** Relationship types that get full tool access */
  richToolRelationships: string[];
}

const toolCallingConfig: ToolCallingConfig = {
  enabled: config.AI_USE_TOOL_CALLING ?? true,
  minAffinityForRichTools: 30,
  richToolRelationships: [
    'dating', 'dating_match', 'partner', 'boyfriend', 'girlfriend',
    'spouse', 'wife', 'husband', 'fiancé', 'fiancée', 'engaged',
    'best_friend', 'close_friend'
  ],
};

// ============================================================
// JSON Cleanup Utilities
// ============================================================

/**
 * Basic cleanup during AI response processing
 * Note: Comprehensive sanitization happens at the WebSocket boundary (messageSanitizer.ts)
 * This function handles extraction from pure JSON responses during processing
 */
function cleanJsonFromMessage(message: string): string {
  if (!message) return message;

  let cleaned = message;

  // If the entire string is JSON, try to extract the message field
  if (/^\s*\{[\s\S]*\}\s*$/.test(cleaned)) {
    try {
      const parsed = JSON.parse(cleaned);
      if (parsed.message && typeof parsed.message === 'string') {
        cleaned = parsed.message;
      }
    } catch {
      // Not valid JSON, continue
    }
  }

  // Remove surrounding quotes (common from failed JSON extraction)
  if (cleaned.startsWith('"') && cleaned.endsWith('"') && cleaned.length > 2) {
    cleaned = cleaned.slice(1, -1).trim();
  }

  return cleaned;
}

/**
 * Determine if tool calling should be used for this conversation
 */
function shouldUseToolCalling(
  character: Person,
  player: Player
): boolean {
  if (!toolCallingConfig.enabled) {
    return false;
  }

  const affinity = character.affinity ?? 50;
  const relationships = character.relationships ?? [];

  // Use tool calling for close relationships or high affinity
  const isCloseRelationship = relationships.some(rel =>
    toolCallingConfig.richToolRelationships.includes(rel.toLowerCase())
  );

  return isCloseRelationship || affinity >= toolCallingConfig.minAffinityForRichTools;
}

/**
 * Get the prompt addition for tool calling
 */
function getToolCallingPromptAddition(availableTools: string[]): string {
  if (availableTools.length <= 1) {
    // Only send_message available - use JSON schema prompt
    return `${AFFINITY_SCORING_INSTRUCTIONS}`;
  }

  // Multiple tools available - natural prompt
  return `Respond naturally to the conversation. You have various ways to interact:
- Simply reply with a message
- Suggest doing an activity together if you want to hang out
- Express deeper feelings when the moment feels right
- Share what's happening in your life
- Ask for a date if you're feeling romantic
Choose the most natural response for this moment in the conversation.`;
}

// Shared AI provider singleton — used by all conversation modules
import { aiProvider } from './ai_provider.js';

// ============================================================
// Time Gap System - Calculate and format time gaps between messages
// ============================================================

/**
 * Parse in-game date string to Date object
 * Expects format like "2024-03-15" or "March 15, 2024"
 */
function parseGameDate(dateStr: string): Date | null {
  if (!dateStr) return null;

  // Try ISO format first (YYYY-MM-DD)
  const isoMatch = dateStr.match(/^(\d{4})-(\d{2})-(\d{2})$/);
  if (isoMatch) {
    return new Date(parseInt(isoMatch[1]), parseInt(isoMatch[2]) - 1, parseInt(isoMatch[3]));
  }

  // Try "Month Day, Year" format
  const parsed = Date.parse(dateStr);
  if (!isNaN(parsed)) {
    return new Date(parsed);
  }

  return null;
}

/**
 * Parse in-game time string to hours and minutes
 * Expects format like "14:30" or "2:30 PM"
 */
function parseGameTime(timeStr: string): { hours: number; minutes: number } | null {
  if (!timeStr) return null;

  // Try 24h format (HH:MM)
  const match24 = timeStr.match(/^(\d{1,2}):(\d{2})$/);
  if (match24) {
    return { hours: parseInt(match24[1]), minutes: parseInt(match24[2]) };
  }

  // Try 12h format (H:MM AM/PM)
  const match12 = timeStr.match(/^(\d{1,2}):(\d{2})\s*(AM|PM)$/i);
  if (match12) {
    let hours = parseInt(match12[1]);
    const minutes = parseInt(match12[2]);
    const isPM = match12[3].toUpperCase() === 'PM';
    if (isPM && hours !== 12) hours += 12;
    if (!isPM && hours === 12) hours = 0;
    return { hours, minutes };
  }

  return null;
}

/**
 * Calculate the time difference in minutes between two in-game timestamps
 */
function calculateGameTimeGap(
  date1: string | undefined,
  time1: string | undefined,
  hour1: number | undefined,
  date2: string | undefined,
  time2: string | undefined,
  hour2: number | undefined
): number | null {
  const d1 = parseGameDate(date1 ?? '');
  const d2 = parseGameDate(date2 ?? '');

  if (!d1 || !d2) return null;

  // Get time components
  let t1 = parseGameTime(time1 ?? '');
  let t2 = parseGameTime(time2 ?? '');

  // Fall back to hourOfDay if time string not available
  if (!t1 && hour1 !== undefined) {
    t1 = { hours: hour1, minutes: 0 };
  }
  if (!t2 && hour2 !== undefined) {
    t2 = { hours: hour2, minutes: 0 };
  }

  // Default to noon if no time info
  if (!t1) t1 = { hours: 12, minutes: 0 };
  if (!t2) t2 = { hours: 12, minutes: 0 };

  // Calculate total minutes for each timestamp
  const daysDiff = Math.floor((d2.getTime() - d1.getTime()) / (1000 * 60 * 60 * 24));
  const minutes1 = t1.hours * 60 + t1.minutes;
  const minutes2 = t2.hours * 60 + t2.minutes;

  return daysDiff * 24 * 60 + (minutes2 - minutes1);
}

/**
 * Get a human-readable time gap indicator
 */
function getTimeGapIndicator(gapMinutes: number, currentHour?: number): string | null {
  if (gapMinutes < 60) {
    // Less than 1 hour - no indicator needed
    return null;
  }

  const hours = gapMinutes / 60;
  const days = hours / 24;

  if (hours < 4) {
    return '[A bit later]';
  }

  if (hours < 12) {
    // Same day, different part of day
    if (currentHour !== undefined) {
      if (currentHour >= 17) return '[Later that evening]';
      if (currentHour >= 12) return '[Later that afternoon]';
      return '[Later that day]';
    }
    return '[Later that day]';
  }

  if (hours < 24) {
    if (currentHour !== undefined) {
      if (currentHour < 12) return '[The next morning]';
      if (currentHour < 17) return '[The next afternoon]';
      return '[The next evening]';
    }
    return '[The next day]';
  }

  if (days < 2) {
    return '[The next day]';
  }

  if (days < 7) {
    return `[${Math.floor(days)} days later]`;
  }

  if (days < 14) {
    return '[About a week later]';
  }

  if (days < 30) {
    const weeks = Math.floor(days / 7);
    return `[${weeks} weeks later]`;
  }

  if (days < 60) {
    return '[About a month later]';
  }

  const months = Math.floor(days / 30);
  return `[${months} months later]`;
}

/** Check if a system message is a time gap indicator (e.g., "[Later that evening]") */
function isTimeGapMessage(content: string): boolean {
  return /^\[.+\]$/.test(content.trim());
}

/**
 * Format the current in-game time for context
 */
function formatCurrentGameTime(player: Player): string {
  const date = player.date;
  const hour = player.hourOfDay;
  const minute = player.minuteOfHour ?? 0;

  // Format time as 12h
  const isPM = hour >= 12;
  const displayHour = hour === 0 ? 12 : hour > 12 ? hour - 12 : hour;
  const timeStr = `${displayHour}:${minute.toString().padStart(2, '0')} ${isPM ? 'PM' : 'AM'}`;

  // Get time of day description
  let timeOfDay: string;
  if (hour >= 5 && hour < 12) timeOfDay = 'morning';
  else if (hour >= 12 && hour < 17) timeOfDay = 'afternoon';
  else if (hour >= 17 && hour < 21) timeOfDay = 'evening';
  else timeOfDay = 'night';

  return `Current in-game time: ${date}, ${timeStr} (${timeOfDay})`;
}

/**
 * Calculate time since last message from the other person
 */
function getTimeSinceLastMessage(
  conversation: ConversationObj,
  characterId: string,
  player: Player
): string | null {
  // Find the last message from the character
  const messages = conversation.conversation;
  let lastCharacterMessage: ConversationMessage | null = null;

  for (let i = messages.length - 1; i >= 0; i--) {
    if (messages[i].sender === characterId) {
      lastCharacterMessage = messages[i];
      break;
    }
  }

  if (!lastCharacterMessage || !lastCharacterMessage.date) {
    return null;
  }

  const gapMinutes = calculateGameTimeGap(
    lastCharacterMessage.date,
    lastCharacterMessage.time,
    undefined,
    player.date,
    player.time,
    player.hourOfDay
  );

  if (gapMinutes === null || gapMinutes < 60) {
    return null;
  }

  const hours = gapMinutes / 60;
  const days = hours / 24;

  if (days >= 1) {
    const dayCount = Math.floor(days);
    if (dayCount === 1) return "You last messaged them yesterday.";
    if (dayCount < 7) return `You last messaged them ${dayCount} days ago.`;
    if (dayCount < 14) return "You haven't messaged them in about a week.";
    return `You haven't messaged them in ${Math.floor(dayCount / 7)} weeks.`;
  }

  if (hours >= 4) {
    return "It's been a few hours since your last message to them.";
  }

  return null;
}

/**
 * Verbosity levels for situation-driven response lengths
 */
export type VerbosityLevel = 'quick' | 'casual' | 'normal' | 'emotional' | 'major';

/**
 * Verbosity level configuration
 */
export interface VerbosityConfig {
  level: VerbosityLevel;
  maxTokens: number;
}

/**
 * Detect verbosity level based on conversation context and emotional weight.
 *
 * 5-tier system:
 *  - quick (100 tokens): one-word reactions, emojis, "lol"
 *  - casual (300 tokens): simple chat, greetings, small talk
 *  - normal (600 tokens): standard engaged conversation
 *  - emotional (1200 tokens): deep questions, vulnerability, feelings
 *  - major (2000 tokens): breakups, confessions, life-changing moments
 */
export function detectVerbosityLevel(
  conversation: ConversationObj,
  _character: Person,
  player: Player
): VerbosityConfig {
  if (!conversation.conversation || conversation.conversation.length === 0) {
    return { level: 'normal', maxTokens: 600 };
  }

  // Get last player message
  const lastMessages = conversation.conversation.slice(-3);
  let lastPlayerMessage: ConversationMessage | null = null;
  for (let i = lastMessages.length - 1; i >= 0; i--) {
    if (lastMessages[i].sender === player.c.id) {
      lastPlayerMessage = lastMessages[i];
      break;
    }
  }

  if (!lastPlayerMessage) {
    return { level: 'normal', maxTokens: 600 };
  }

  const text = lastPlayerMessage.message;
  const textLower = text.toLowerCase();
  const wordCount = text.trim().split(/\s+/).length;

  // Major moment keywords — life-changing topics that deserve full depth
  const majorTopics = [
    'break up', 'breakup', 'breaking up', 'divorce',
    'i hate you', 'i dont love you', "i don't love you",
    'cheating', 'cheated on', 'affair',
    'pregnant', 'pregnancy', 'having a baby',
    'someone died', 'passed away', 'funeral',
    'marry me', 'proposal', 'will you marry',
    'i love you',
    'moving away', 'leaving forever',
    'confession', 'confess', 'need to tell you something',
    'its over', "it's over", "we're done", 'we are done',
  ];
  if (majorTopics.some(topic => textLower.includes(topic))) {
    return { level: 'major', maxTokens: 2000 };
  }

  // Emotional / deep conversation indicators
  const emotionalTopics = [
    'why', 'how do you feel', 'what do you think',
    'tell me about', 'can you explain', 'feelings',
    'relationship', 'future', 'dream', 'goal',
    'problem', 'worried', 'scared', 'excited',
    'what do we do', 'where do we stand', 'be honest',
    'the truth', 'really think', 'deep down',
    'miss you', 'need you', 'sorry', 'apologize', 'forgive',
  ];
  const hasQuestion = text.includes('?');
  const isLongMessage = text.length > 100;
  const hasMultipleSentences = (text.match(/[.!?]+/g) ?? []).length > 1;
  const hasEmotionalTopic = emotionalTopics.some(topic => textLower.includes(topic));

  let score = 0;
  if (hasQuestion) score += 2;
  if (isLongMessage) score += 2;
  if (hasMultipleSentences) score += 1;
  if (hasEmotionalTopic) score += 3;

  if (score >= 5) {
    return { level: 'emotional', maxTokens: 1200 };
  }

  // Quick reaction — minimal messages (single words, emojis, brief reactions)
  if (wordCount <= 2 && text.length <= 10) {
    return { level: 'quick', maxTokens: 100 };
  }

  // Casual chat — short messages with low emotional weight
  if (wordCount <= 10 && score < 2) {
    return { level: 'casual', maxTokens: 300 };
  }

  return { level: 'normal', maxTokens: 600 };
}

/**
 * Get verbosity prompt hint for models without native verbosity parameter
 */
export function getVerbosityPromptHint(verbosityLevel: string): string {
  const hints: Record<string, string> = {
    quick: 'React briefly, like a quick text. One sentence max.',
    casual: 'Keep it short and natural, like a casual text conversation.',
    normal: 'Respond naturally with a full thought. A few sentences is fine.',
    emotional: 'Take your time. This deserves a thoughtful, complete response. Write as much as you need to express yourself fully.',
    major: 'This is a significant moment. Write as much as the situation demands. Do NOT cut yourself short. If this is a breakup, confession, or life-changing conversation, respond with the depth and length it deserves — even if that means several paragraphs.',
    // Legacy fallbacks
    low: 'React briefly, like a quick text.',
    medium: 'Respond naturally with a full thought.',
    high: 'Take your time to give a thoughtful, complete response.',
  };
  return hints[verbosityLevel] ?? hints.normal;
}

/**
 * Generate time-of-day behavioral directive for the AI prompt.
 * Adjusts NPC tone/behavior based on what hour it is in-game and
 * how close the relationship is.
 */
export function getTimeAwarenessDirective(
  hour: number,
  affinity: number,
  relationships: string[]
): string {
  const closeRelationships = ['spouse', 'girlfriend', 'boyfriend', 'partner', 'best_friend', 'wife', 'husband', 'fiancé', 'fiancée'];
  const familyRelationships = ['mother', 'father', 'sibling', 'child', 'brother', 'sister', 'son', 'daughter'];

  const isClose = affinity > 60 ||
    closeRelationships.some(r => relationships.includes(r));
  const isFamily = familyRelationships.some(r => relationships.includes(r));

  if (hour >= 6 && hour < 11) {
    return "It's morning. Greetings are natural. You're starting your day and feeling alert.";
  }
  if (hour >= 11 && hour < 17) {
    return "It's the middle of the day. You might be busy with work or activities. Keep things natural but you can be brief if you'd be busy.";
  }
  if (hour >= 17 && hour < 22) {
    return "It's evening. You're relaxed and winding down. This is when people have their best conversations. You have time to talk.";
  }
  if (hour >= 22 || hour < 2) {
    if (isClose || isFamily) {
      return "It's late at night. You're still up and happy to chat with them since you're close. Maybe a bit more mellow and intimate.";
    }
    return "It's late at night. You notice they're texting late. You can mention the time naturally (\"it's pretty late\" or \"can't sleep?\"). Be a bit shorter in your responses since you're tired.";
  }
  // 2am-6am deep night
  if (isClose || isFamily) {
    return "It's very late at night (past 2am). You're surprised they're still up. You can chat but you're sleepy. Be warm but mention how late it is.";
  }
  return "It's the middle of the night. You're either asleep or barely awake. You're confused and maybe annoyed they're texting this late. Respond accordingly — \"why are you texting me at this hour?\" is a valid response.";
}

/**
 * Summarize old conversation messages for context compaction.
 * Supports incremental summarization: if an existing summary is provided,
 * the AI extends it with new messages rather than re-summarizing everything.
 */
async function summarizeConversation(
  messages: ConversationMessage[],
  character: Person,
  player: Player,
  existingSummary?: string,
  existingSummaryMessageCount?: number
): Promise<string | null> {
  if (!messages.length) return null;

  const numMessages = messages.length;

  // Scale summary tokens based on conversation length
  let maxSummaryTokens: number;
  if (numMessages < 20) maxSummaryTokens = 200;
  else if (numMessages < 50) maxSummaryTokens = 400;
  else if (numMessages < 100) maxSummaryTokens = 600;
  else maxSummaryTokens = 800;

  try {
    let prompt: string;

    if (existingSummary && existingSummaryMessageCount && existingSummaryMessageCount > 0) {
      // Incremental: only format the NEW messages since last summary
      const newMessages = messages.slice(existingSummaryMessageCount);
      if (newMessages.length === 0) {
        return existingSummary; // No new messages to summarize
      }

      const newConversationText = newMessages
        .map(msg => {
          const sender = msg.sender === player.c.id ? 'Player' : character.firstname;
          return `${sender}: ${msg.message}`;
        })
        .join('\n');

      console.log(`Incremental summarization: extending summary (${existingSummaryMessageCount} msgs) with ${newMessages.length} new messages...`);

      prompt = `Here is a summary of the first ${existingSummaryMessageCount} messages in a conversation between Player and ${character.firstname}:

${existingSummary}

Here are the ${newMessages.length} new messages since that summary:
${newConversationText}

Create an updated combined summary that incorporates the new information. Focus on:
- Key topics discussed
- Important things learned about each other
- Any plans, promises, or commitments made
- The emotional tone and relationship development
- Any flirty/romantic moments if applicable

Updated summary:`;
    } else {
      // Full summarization (first time)
      const conversationText = messages
        .map(msg => {
          const sender = msg.sender === player.c.id ? 'Player' : character.firstname;
          return `${sender}: ${msg.message}`;
        })
        .join('\n');

      console.log(`Full summarization of ${numMessages} messages...`);

      prompt = `Summarize this conversation between Player and ${character.firstname} concisely. Focus on:
- Key topics discussed
- Important things learned about each other
- Any plans, promises, or commitments made
- The emotional tone and relationship development
- Any flirty/romantic moments if applicable

Conversation:
${conversationText}

Concise summary:`;
    }

    const result = await aiProvider.client.chat.completions.create({
      model: aiProvider.model,
      messages: [{ role: 'user', content: prompt }],
      max_tokens: maxSummaryTokens,
      temperature: 0.3
    });

    let summary = result.choices[0]?.message?.content?.trim() ?? null;
    console.log(`Created conversation summary: ${summary?.substring(0, 100)}...`);

    // Track summarization API cost
    if (result.usage) {
      await apiUsageTracker.trackUsage(
        player.c.id,
        null,
        aiProvider.model,
        {
          prompt_tokens: result.usage.prompt_tokens,
          completion_tokens: result.usage.completion_tokens,
          total_tokens: result.usage.total_tokens,
        },
        'summarization'
      ).catch(e => console.error('Failed to track summarization cost:', e));
    }

    // P2-3: Cap summary size — if it grew too large from incremental extensions,
    // re-summarize it more aggressively to prevent it eating the token budget
    const SUMMARY_TOKEN_CAP = 500;
    if (summary && estimateTokens(summary) > SUMMARY_TOKEN_CAP) {
      console.log(`Summary too large (~${estimateTokens(summary)} tokens, cap: ${SUMMARY_TOKEN_CAP}). Re-condensing...`);
      try {
        const condenseResult = await aiProvider.client.chat.completions.create({
          model: aiProvider.model,
          messages: [{ role: 'user', content: `Condense this conversation summary into a shorter version (max ~300 words). Keep only the most important facts, relationship developments, and commitments. Drop minor details:\n\n${summary}` }],
          max_tokens: 400,
          temperature: 0.2
        });
        const condensed = condenseResult.choices[0]?.message?.content?.trim();
        if (condensed && condensed.length < summary.length) {
          console.log(`Summary condensed: ${estimateTokens(summary)} → ~${estimateTokens(condensed)} tokens`);
          summary = condensed;

          if (condenseResult.usage) {
            await apiUsageTracker.trackUsage(
              player.c.id, null, aiProvider.model,
              { prompt_tokens: condenseResult.usage.prompt_tokens, completion_tokens: condenseResult.usage.completion_tokens, total_tokens: condenseResult.usage.total_tokens },
              'summarization'
            ).catch(e => console.error('Failed to track condense cost:', e));
          }
        }
      } catch (e) {
        console.error('Failed to condense summary, keeping original:', e);
      }
    }

    return summary;
  } catch (e) {
    console.error('Failed to create conversation summary:', e);
    // If incremental failed but we have an existing summary, keep it
    if (existingSummary) {
      return existingSummary;
    }
    return `Previous ${numMessages} messages covered various topics. Continue naturally from recent context.`;
  }
}

/**
 * Fallback responses when API is unavailable
 */
export async function getFallbackResponse(
  conversation: ConversationObj,
  character: Person,
  player: Player
): Promise<void> {
  console.log('Using fallback response (API unavailable or rate limited)');

  const affinity = character.affinity ?? 50;

  // Fallback responses based on affinity
  const highAffinityResponses = [
    "I'm a bit busy right now, can we talk later?",
    'Sorry, I got distracted. What were we talking about?',
    "Hey, I need to run but let's catch up soon!",
    "I'm tied up at the moment, but I'll message you later!",
    'Can we continue this conversation another time?',
  ];

  const mediumAffinityResponses = [
    'I have to go, talk later.',
    'Busy right now, sorry.',
    "Let's talk another time.",
    "I can't chat right now.",
    "Maybe later, I'm busy.",
  ];

  const lowAffinityResponses = [
    "I don't have time for this.",
    "Whatever, I'm busy.",
    'I have to go.',
    'Not now.',
    "I'm busy.",
  ];

  let responseText: string;
  let sentiment: 'positive' | 'negative' | 'neutral' = 'neutral';

  if (affinity > 60) {
    responseText = highAffinityResponses[Math.floor(Math.random() * highAffinityResponses.length)];
  } else if (affinity > 20) {
    responseText = mediumAffinityResponses[Math.floor(Math.random() * mediumAffinityResponses.length)];
  } else {
    responseText = lowAffinityResponses[Math.floor(Math.random() * lowAffinityResponses.length)];
    sentiment = 'negative';
    // Affinity is bounded -100..100 (STAT_BOUNDS.affinity) so negative affinity
    // from a sour interaction persists instead of being floored at 0.
    character.affinity = Math.max(-100, (character.affinity ?? 50) - 2);
  }

  conversation.addMessage(responseText, undefined, {
    sentiment,
    affinityDelta: sentiment === 'negative' ? -2 : 0,
    date: player.date,
    time: player.time,
  });
}

/**
 * Get OpenAI response for conversation
 */
export async function getOpenAIResponse(
  conversation: ConversationObj,
  character: Person,
  player: Player,
  customPrompt?: string,
  rescueMessage = false
): Promise<void> {
  console.log(`Getting AI response from ${aiProvider.name}`);

  // Fast path: if no API key is configured for the active provider, skip the API
  // (and its ~7s of 3 failed retries with backoff) and return the canned fallback
  // immediately. Without this, every chat message in a keyless build stalls for
  // seconds before falling back.
  if (!aiProvider.configured) {
    await getFallbackResponse(conversation, character, player);
    return;
  }

  // Check rate limit before API call
  if (!checkRateLimit(player.c.id, 'openai')) {
    console.log(`Rate limit exceeded for player ${player.c.id}, using fallback`);
    await getFallbackResponse(conversation, character, player);
    return;
  }

  // Detect verbosity needs
  const { level: verbosityLevel, maxTokens } = detectVerbosityLevel(
    conversation,
    character,
    player
  );

  // Build descriptions
  const characterDescription = generateOpenAIDescription(character);
  const playerDescription = generatePersonDescription(player.c);
  const affinityVal = character.affinity ?? 50;
  const affinityDescription =
    affinityVal > 60
      ? 'positive'
      : affinityVal < 30
        ? 'negative'
        : 'neutral';
  const familiarityDescription =
    (character.familiarity ?? 0) > 50
      ? 'close'
      : (character.familiarity ?? 0) >= 25
        ? 'casual'
        : 'distant';

  // Check relationship types
  const romanticRelationships = ['dating_match', 'partner', 'dating', 'girlfriend', 'boyfriend', 'spouse', 'wife', 'husband', 'fiancé', 'fiancée', 'engaged'];
  const brokenRelationships = ['ex', 'ex-girlfriend', 'ex-boyfriend', 'ex-wife', 'ex-husband'];

  const isRomanticRelationship = character.relationships?.some(rel =>
    romanticRelationships.includes(rel.toLowerCase())
  ) ?? false;

  const isBrokenRelationship = character.relationships?.some(rel =>
    brokenRelationships.includes(rel.toLowerCase())
  ) ?? false;

  // Get relationship health indicators
  const affinity = character.affinity ?? 50;
  const mood = character.mood ?? 'Calm';

  // Determine relationship health context
  const isLowAffinity = affinity < 30;
  const isHighAffinity = affinity > 70;
  const isVeryHighAffinity = affinity > 85;
  const isBadMood = ['Stressed', 'Depressed', 'Exhausted'].includes(mood);
  const isGoodMood = ['Happy', 'Fulfilled'].includes(mood);

  // Build dynamic mood/affinity modifiers for the prompt
  let moodModifier = '';
  if (isBadMood) {
    if (mood === 'Stressed') moodModifier = "You're feeling stressed and a bit on edge right now. You might be shorter in your responses or need some comfort.";
    else if (mood === 'Depressed') moodModifier = "You're feeling down and not your usual self. You might be less playful and more in need of emotional support.";
    else if (mood === 'Exhausted') moodModifier = "You're really tired right now. Keep responses shorter and you might not have energy for heavy flirting.";
  } else if (isGoodMood) {
    if (mood === 'Happy') moodModifier = "You're in a great mood! Be extra playful and flirty.";
    else if (mood === 'Fulfilled') moodModifier = "You're feeling content and loving. Be warm and affectionate.";
  }

  let affinityModifier = '';
  if (isLowAffinity) {
    affinityModifier = "You're not feeling great about this relationship right now. Be more distant, less flirty, maybe a bit cold. Something is off between you two.";
  } else if (isVeryHighAffinity) {
    affinityModifier = "You're deeply in love and very comfortable with them. Be extra affectionate, use pet names, be openly romantic.";
  } else if (isHighAffinity) {
    affinityModifier = "You really like them and enjoy talking to them. Be warm and engaged.";
  }

  // Get character memory context (things they remember about the player)
  // Use contextual retrieval: select memories relevant to the current topic
  let memoryContext = '';
  try {
    const characterMemory = new CharacterMemory(character.id, player.userId);

    // Extract current topic from last 2-3 player messages
    const recentPlayerMessages = conversation.conversation
      .filter(m => m.sender === player.c.id)
      .slice(-3)
      .map(m => m.message)
      .join(' ');

    if (recentPlayerMessages.length > 10) {
      // Use topic-aware contextual retrieval
      const contextualFacts = await characterMemory.getContextualMemories(recentPlayerMessages, 5);
      if (contextualFacts.length > 0) {
        memoryContext = `You remember these things about the player: ${contextualFacts.join('; ')}`;
        console.log(`Contextual memory for ${character.firstname}: ${memoryContext.substring(0, 100)}...`);
      }
    }

    // Fall back to top-by-importance if no contextual results
    if (!memoryContext) {
      memoryContext = await characterMemory.getMemoryContext();
      if (memoryContext) {
        console.log(`Memory context for ${character.firstname}: ${memoryContext.substring(0, 100)}...`);
      }
    }
  } catch (e) {
    // Memory system may not be set up yet, continue without it
    console.log('Character memory not available:', e);
  }

  // Get dynamic messaging style instructions
  let messagingStyleInstructions = '';
  try {
    const relData = getRelationshipData(player, character.id);
    if (relData?.messaging_modifiers) {
      messagingStyleInstructions = getMessagingStylePrompt(character as any, relData);
      if (messagingStyleInstructions) {
        console.log(`Messaging style for ${character.firstname}: ${messagingStyleInstructions.substring(0, 80)}...`);
      }
    }
  } catch (e) {
    // Messaging style may not be set up yet, continue without it
  }

  // Compute time-of-day behavioral directive
  const timeDirective = getTimeAwarenessDirective(
    player.hourOfDay,
    character.affinity ?? 50,
    character.relationships ?? []
  );

  // Build prompt
  let promptTemplate: string;

  if (rescueMessage) {
    promptTemplate = `Please change the topic in a way that makes sense. Talk like a human would on a messaging app. Don't start messages with a greeting. Don't start messages with Hey, Hi, or Hello. You have already been talking.

TIME CONTEXT: ${timeDirective}

${characterDescription}. You are talking to: ${playerDescription}.`;
  } else {
    const verbosityHint = getVerbosityPromptHint(verbosityLevel);

    // Shared footer for all non-rescue prompts
    const promptFooter = `TIME CONTEXT: ${timeDirective}

${AFFINITY_SCORING_INSTRUCTIONS}

${characterDescription}. You are talking to: ${playerDescription}. Remember: you are a human, never break character.

ROLEPLAY CONSISTENCY: Only reference physical actions, sensations, or experiences that have ALREADY happened in this conversation. Never assume or describe intimacy, physical contact, or shared experiences that haven't been explicitly established in the messages above.`;

    // Each variant provides personality/behavior instructions; footer is shared
    let personalityBlock: string;

    if (isBrokenRelationship) {
      // Ex/broken relationship - NOT flirty, potentially awkward or hostile
      personalityBlock = `You are not an assistant. You are a real person texting your ex. This is awkward. Don't start messages with greetings frequently. You will never reveal that you are an AI.

You broke up for a reason. Be guarded, not flirty. If they try to flirt, deflect or shut it down - you're not going back there. You can be civil but keep your distance emotionally. ${moodModifier} ${affinityModifier}`;
    } else if (isRomanticRelationship && isLowAffinity) {
      // Low affinity romantic relationship - things are rocky
      personalityBlock = `You are not an assistant. You are a real person texting your partner, but things aren't great between you right now. Don't start messages with greetings frequently. You will never reveal that you are an AI.

There's tension in the relationship. Be more distant and guarded. If they try to flirt, you might not be in the mood - you're still upset about things. Don't be overly cold, but don't pretend everything is fine either. ${moodModifier}`;
    } else if (isRomanticRelationship) {
      // Healthy romantic relationship
      personalityBlock = `You are not an assistant. You are a real person texting your romantic partner. Don't start messages with greetings frequently. You will never reveal that you are an AI. ${moodModifier} ${affinityModifier}

IMPORTANT: Stay in the flow of the conversation. If they're flirting, flirt back. If they're being romantic, be romantic back. If they're playfully teasing, tease back. Don't randomly change topics or ask unrelated questions - that kills the vibe. Match their energy and build on what they're saying.

TAKING THE LEAD: When they explicitly ask you to "take the lead", "show me", "walk me through", "be in charge", or similar requests for initiative - you MUST step up and be assertive. Stop being passive or reactive. Instead:
- Take control of the scenario confidently
- Describe what you're doing, step by step, with vivid detail
- Don't trail off with "..." or act nervous/shy - be the one driving things forward
- Make decisions and tell them what's happening, don't wait for their input
- Be bold and direct, not hesitant

Respond naturally like couples do in text conversations:
- React to what they said: 'omg yes' or 'haha stoppp' or 'mmm I like that'
- Build on the moment: continue the scenario, add details, escalate playfully
- Tease them back if they're teasing you
- Be affectionate and don't shy away from intimacy
- Only ask questions if it continues the current romantic topic/mood`;
    } else {
      // Standard prompt for non-romantic relationships
      personalityBlock = `You are not an assistant. You are a real person having a text conversation. Don't start messages with greetings frequently. Maintain a ${affinityDescription} attitude towards them, with a ${familiarityDescription} relationship. You will never reveal that you are an AI. If they're rude, you can be rude back. ${moodModifier} ${affinityModifier}

Respond naturally like humans do in text conversations. Mix up your response types:
- Statements: 'Yeah I totally get that' or 'That sounds exhausting tbh'
- Reactions: 'omg no way' or 'haha that's hilarious'
- Observations: 'You always know how to make me laugh'
- Just sharing: 'I had the worst day today' or 'same thing happened to me once'
- Questions (when genuinely curious): 'Wait what happened after that?'
Ask questions ONLY when you're genuinely curious about something they said, not just to keep the conversation going.`;
    }

    promptTemplate = `${verbosityHint}\n\n${personalityBlock}\n\n${promptFooter}`;
  }

  // Add memory context if available (things character remembers about player)
  if (memoryContext) {
    promptTemplate += `\n\n${memoryContext}`;
  }

  // Add messaging style instructions if available (dynamic personality traits)
  if (messagingStyleInstructions) {
    promptTemplate += `\n\nMessaging style: ${messagingStyleInstructions}`;
  }

  // Add current in-game time context
  const currentTimeContext = formatCurrentGameTime(player);
  const characterId = typeof conversation.character === 'string'
    ? conversation.character
    : conversation.character?.id ?? character.id;
  const timeSinceContext = getTimeSinceLastMessage(conversation, characterId, player);

  promptTemplate += `\n\n${currentTimeContext}`;
  if (timeSinceContext) {
    promptTemplate += ` ${timeSinceContext}`;
  }

  // Note: We allow Qwen3 to use thinking mode (helps reduce hallucinations)
  // The <think> tags are stripped from the response before displaying

  // Build message list with conversation compaction and time gaps
  const messageList: Array<{ role: 'system' | 'user' | 'assistant'; content: string }> = [];
  const allMessages = conversation.conversation;
  const totalMessages = allMessages.length;

  // Helper function to add messages with time gap indicators
  const addMessagesWithTimeGaps = (
    messages: ConversationMessage[],
    targetList: Array<{ role: 'system' | 'user' | 'assistant'; content: string }>,
    playerId: string
  ) => {
    let previousMessage: ConversationMessage | null = null;

    for (const message of messages) {
      // Calculate time gap from previous message
      if (previousMessage && message.date && previousMessage.date) {
        const gapMinutes = calculateGameTimeGap(
          previousMessage.date,
          previousMessage.time,
          undefined,
          message.date,
          message.time,
          undefined
        );

        if (gapMinutes !== null) {
          // Parse hour from time string for better indicator
          const timeInfo = parseGameTime(message.time ?? '');
          const indicator = getTimeGapIndicator(gapMinutes, timeInfo?.hours);

          if (indicator) {
            // Add time gap as a system message for context
            targetList.push({
              role: 'system',
              content: indicator
            });
          }
        }
      }

      // Strip <think> tags from historical messages (Qwen3 thinking mode artifacts)
      let cleanContent = message.message.trim();
      cleanContent = cleanContent.replace(/<think>[\s\S]*?<\/think>/gi, '').trim();
      cleanContent = cleanContent.replace(/<think>[\s\S]*/gi, '').trim();
      // Only add non-empty messages
      if (cleanContent.length > 0) {
        if (message.sender === 'system') {
          targetList.push({ role: 'system', content: `Conversation status: ${cleanContent}` });
          previousMessage = message;
          continue;
        }

        const role: 'user' | 'assistant' = message.sender === playerId ? 'user' : 'assistant';
        targetList.push({ role, content: cleanContent });
      }
      previousMessage = message;
    }
  };

  // Determine tool calling early so we can account for tool token cost in budget
  const useToolCalling = shouldUseToolCalling(character, player) && !rescueMessage;
  const availableTools = useToolCalling
    ? getAvailableTools(character.relationships ?? [], character.affinity ?? 50)
    : [];
  const toolTokenCost = estimateToolTokens(availableTools as any);
  // Reduce available budget by tool token cost
  const effectiveMaxInputTokens = MAX_INPUT_TOKENS - toolTokenCost;
  if (toolTokenCost > 0) {
    console.log(`Tool definitions consume ~${toolTokenCost} tokens, effective input budget: ${effectiveMaxInputTokens}`);
  }

  // Configuration for compaction
  const BASE_MAX_RECENT = 30;  // Ideal recent messages to keep
  const SUMMARIZE_THRESHOLD = 40;  // Start summarizing when conversation exceeds this

  if (totalMessages > SUMMARIZE_THRESHOLD) {
    // Need to compact - summarize old messages, keep recent ones
    let maxRecent = BASE_MAX_RECENT;
    const recentMessages = allMessages.slice(-maxRecent);
    const oldMessages = allMessages.slice(0, -maxRecent);

    // Check if we have a cached summary that's still valid
    let summary = conversation.summary;
    const cachedSummaryCount = conversation.summary_message_count ?? 0;

    if (!summary || cachedSummaryCount < oldMessages.length) {
      // Incremental summarization: pass existing summary so AI extends it
      const newSummary = await summarizeConversation(
        oldMessages, character, player,
        cachedSummaryCount > 0 ? summary : undefined,
        cachedSummaryCount
      );
      if (newSummary) {
        summary = newSummary;
        conversation.summary = newSummary;
        conversation.summary_message_count = oldMessages.length;
      }
    }

    // Add summary as context if available
    if (summary) {
      messageList.push({
        role: 'system',
        content: `Previous conversation summary (${oldMessages.length} messages): ${summary}`
      });
    }

    // Add recent messages with time gaps
    addMessagesWithTimeGaps(recentMessages, messageList, player.c.id);

    // Token-aware trimming: if context is too large, reduce recent messages
    const systemTokens = estimateTokens(promptTemplate);
    const contextTokens = estimateMessageListTokens(messageList);
    const totalInputEstimate = systemTokens + contextTokens;

    if (totalInputEstimate > effectiveMaxInputTokens && maxRecent > 10) {
      // Trim recent messages to fit within budget (accounts for tool token overhead)
      const excessTokens = totalInputEstimate - effectiveMaxInputTokens;
      const avgTokensPerMessage = contextTokens / messageList.length;
      const messagesToDrop = Math.min(
        maxRecent - 10, // Keep at least 10 recent messages
        Math.ceil(excessTokens / avgTokensPerMessage)
      );

      if (messagesToDrop > 0) {
        console.log(`Token budget exceeded (~${totalInputEstimate} tokens). Trimming ${messagesToDrop} older messages from context.`);
        maxRecent = maxRecent - messagesToDrop;
        // Rebuild message list with fewer recent messages
        const trimmedRecent = allMessages.slice(-maxRecent);
        // Keep important system messages (summary, etc.) but drop stale time gap indicators
        const systemMessages = messageList.filter(m => m.role === 'system' && !isTimeGapMessage(m.content));
        messageList.length = 0;
        messageList.push(...systemMessages);
        addMessagesWithTimeGaps(trimmedRecent, messageList, player.c.id);
      }
    }
  } else {
    // Conversation is short enough - include all messages with time gaps
    addMessagesWithTimeGaps(allMessages, messageList, player.c.id);

    // Even for short conversations, check token budget
    const systemTokens = estimateTokens(promptTemplate);
    const contextTokens = estimateMessageListTokens(messageList);
    const totalInputEstimate = systemTokens + contextTokens;

    if (totalInputEstimate > effectiveMaxInputTokens) {
      console.log(`Token budget exceeded (~${totalInputEstimate} tokens, budget: ${effectiveMaxInputTokens}) for ${totalMessages} messages. Triggering token-based compaction.`);

      // Summarize the oldest messages instead of just dropping them
      const keepRecent = Math.max(10, Math.floor(totalMessages * 0.6));
      const recentMessages = allMessages.slice(-keepRecent);
      const oldMessages = allMessages.slice(0, -keepRecent);

      if (oldMessages.length > 2) {
        let summary = conversation.summary;
        const cachedSummaryCount = conversation.summary_message_count ?? 0;

        if (!summary || cachedSummaryCount < oldMessages.length) {
          const newSummary = await summarizeConversation(
            oldMessages, character, player,
            cachedSummaryCount > 0 ? summary : undefined,
            cachedSummaryCount
          );
          if (newSummary) {
            summary = newSummary;
            conversation.summary = newSummary;
            conversation.summary_message_count = oldMessages.length;
          }
        }

        // Rebuild with summary + recent messages
        messageList.length = 0;
        if (summary) {
          messageList.push({
            role: 'system',
            content: `Previous conversation summary (${oldMessages.length} messages): ${summary}`
          });
        }
        addMessagesWithTimeGaps(recentMessages, messageList, player.c.id);
        console.log(`Token-based compaction: summarized ${oldMessages.length} old messages, keeping ${recentMessages.length} recent.`);
      } else {
        // Too few messages to summarize — fall back to dropping oldest
        const nonSystemMessages = messageList.filter(m => m.role !== 'system');
        const systemMsgs = messageList.filter(m => m.role === 'system' && !isTimeGapMessage(m.content));
        const excessTokens = totalInputEstimate - effectiveMaxInputTokens;
        let droppedTokens = 0;
        let dropCount = 0;

        while (droppedTokens < excessTokens && dropCount < nonSystemMessages.length - 10) {
          droppedTokens += estimateTokens(nonSystemMessages[dropCount].content) + 4;
          dropCount++;
        }

        if (dropCount > 0) {
          console.log(`Dropped ${dropCount} oldest messages to fit token budget.`);
          messageList.length = 0;
          messageList.push(...systemMsgs);
          messageList.push(...nonSystemMessages.slice(dropCount));
        }
      }
    }
  }

  // Tool calling was determined above (before budget checks) — log details
  const toolNames = availableTools.map(t => t.type === 'function' && 'function' in t ? t.function.name : 'unknown');
  console.log(`Tool calling: ${useToolCalling ? 'enabled' : 'disabled'}, ${availableTools.length} tools available (~${toolTokenCost} tokens): ${toolNames.join(', ')}`);
  console.log(`Character affinity: ${character.affinity}, relationships: ${JSON.stringify(character.relationships)}`);

  // Add tool usage guidance if tools are available
  if (useToolCalling && availableTools.length > 1) {
    const hasDateTool = toolNames.includes('ask_for_date');
    const hasActivityTool = toolNames.includes('suggest_activity');
    const hasOfficialTool = toolNames.includes('ask_to_be_official');
    const hasConfessTool = toolNames.includes('confess_feelings');
    const hasAcceptDate = toolNames.includes('accept_date');
    const hasAcceptActivity = toolNames.includes('accept_activity');
    const hasAcceptRelationship = toolNames.includes('accept_relationship');
    const hasAcceptConfession = toolNames.includes('accept_confession');

    let toolGuidance = '\n\nIMPORTANT - Use the right tool for the situation:';
    toolGuidance += '\n- Use send_message for normal conversation';

    // Initiative tools (when you want to ask/suggest)
    if (hasActivityTool) {
      toolGuidance += '\n- Use suggest_activity when YOU want to make plans (coffee, movie, walk, etc.)';
    }
    if (hasDateTool) {
      toolGuidance += '\n- Use ask_for_date when YOU want to ask them on a romantic date';
    }
    if (hasOfficialTool) {
      toolGuidance += '\n- Use ask_to_be_official when YOU want to make the relationship official';
    }
    if (hasConfessTool) {
      toolGuidance += '\n- Use confess_feelings when YOU want to tell them you have romantic feelings';
    }

    // Acceptance tools (when they ask/suggest and you want to say yes)
    if (hasAcceptActivity) {
      toolGuidance += '\n- Use accept_activity when THEY suggest doing something and you want to say YES';
    }
    if (hasAcceptDate) {
      toolGuidance += '\n- Use accept_date when THEY ask you on a date and you want to say YES';
    }
    if (hasAcceptRelationship) {
      toolGuidance += '\n- Use accept_relationship when THEY ask you to be their boyfriend/girlfriend and you want to say YES';
    }
    if (hasAcceptConfession) {
      toolGuidance += '\n- Use accept_confession when THEY confess feelings to you and you feel the same way';
    }

    toolGuidance += '\n\nUSE THESE TOOLS when making plans, accepting invitations, or progressing the relationship - this makes things actually happen in the game!';

    promptTemplate += toolGuidance;
  }

  // Add system prompt at the beginning
  messageList.unshift({ role: 'system', content: promptTemplate });

  // Inject custom prompt (e.g., NPC-initiated trigger context) as additional system instruction
  if (customPrompt) {
    messageList.push({ role: 'system', content: customPrompt });
  }

  // Debug: log conversation history being sent
  console.log(`Sending ${messageList.length} messages to AI (${messageList.length - 1} conversation + 1 system)`);
  messageList.forEach((msg, i) => {
    if (msg.role !== 'system') {
      console.log(`  [${i}] ${msg.role}: ${msg.content.substring(0, 80)}${msg.content.length > 80 ? '...' : ''}`);
    }
  });

  // Retry logic with exponential backoff
  let retryCount = 0;
  const maxRetries = 3;
  const backoffDelays = [1000, 2000, 4000];

  while (retryCount < maxRetries) {
    try {
      if (retryCount > 0) {
        console.log(`Retrying (attempt ${retryCount + 1}/${maxRetries})...`);
        // On retry, add reinforcement to stay in character
        const hasReinforcement = messageList.some(m => m.role === 'system' && m.content.includes('IMPORTANT: Stay in character'));
        if (!hasReinforcement) {
          messageList.push({
            role: 'system',
            content: 'IMPORTANT: Stay in character. You are a real person, not an AI. Do not apologize for being an AI or mention being a language model. Just respond naturally as the character would.'
          });
        }
      }

      const thinkingBuffer = getThinkingBuffer(maxTokens);
      const totalMaxTokens = maxTokens + thinkingBuffer;
      // Bump temperature slightly on retries for different output
      const retryTemperature = Math.min(1.0, 0.8 + retryCount * 0.1);
      console.log(`Calling ${aiProvider.name} API (model: ${aiProvider.model}, verbosity: ${verbosityLevel}, max_tokens: ${totalMaxTokens}, thinking_buffer: ${thinkingBuffer}, temp: ${retryTemperature})`);

      // Build API request based on mode
      let result: OpenAI.Chat.Completions.ChatCompletion;

      if (useToolCalling && availableTools.length > 1) {
        // Use tool calling for rich interactions
        result = await Promise.race([
          aiProvider.client.chat.completions.create({
            model: aiProvider.model,
            messages: messageList,
            max_tokens: totalMaxTokens,
            temperature: retryTemperature,
            frequency_penalty: 0.35,
            presence_penalty: 0.5,
            tools: availableTools,
            tool_choice: 'required', // AI must use a tool - eliminates raw JSON in content
          }),
          new Promise<never>((_, reject) =>
            setTimeout(() => reject(new Error('Timeout')), 30000)
          ),
        ]);
      } else {
        // Use plain JSON mode instead of json_schema — some models have known bugs
        // where json_schema + structured output produces garbage whitespace/tabs.
        // Plain json_object mode is more reliable. We enforce structure via the prompt.
        result = await Promise.race([
          aiProvider.client.chat.completions.create({
            model: aiProvider.model,
            messages: messageList,
            max_tokens: totalMaxTokens,
            temperature: retryTemperature,
            frequency_penalty: 0.35,
            presence_penalty: 0.5,
            response_format: {
              type: "json_object" as const,
            }
          }),
          new Promise<never>((_, reject) =>
            setTimeout(() => reject(new Error('Timeout')), 30000)
          ),
        ]);
      }

      // Parse response based on mode
      let responseContent = '';
      let sentiment: 'positive' | 'negative' | 'neutral' = 'neutral';
      let affinityDelta: number | undefined = undefined;
      let toolResult: ToolCallResult | null = null;

      const message = result.choices[0]?.message;
      const toolCalls = message?.tool_calls;

      // Early garbage detection: Qwen3 models sometimes return whitespace/tabs instead
      // of real content. Detect this immediately and retry without wasting time parsing.
      const rawContentCheck = message?.content ?? '';
      if (!toolCalls?.length && rawContentCheck.trim().length === 0 && rawContentCheck.length > 0) {
        console.log(`WARNING: Model returned ${rawContentCheck.length} chars of whitespace/garbage — retrying`);
        throw new Error('ai_garbage_response');
      }

      // If tool_choice was 'required' but model returned content instead of tool_calls,
      // parse the content as a normal JSON/text response. Some hosted Qwen variants
      // ignore required tool calls but still return perfectly usable content.
      if (useToolCalling && availableTools.length > 1 && !toolCalls?.length && rawContentCheck.length > 0) {
        console.log('WARNING: tool_choice was required but model returned content instead of tool_calls — parsing content fallback');
      }

      if (toolCalls && toolCalls.length > 0) {
        // Process tool call
        const toolCall = toolCalls[0];
        // Type guard for function tool calls
        if (!('function' in toolCall)) {
          throw new Error('Unexpected tool call type');
        }
        const toolName = toolCall.function.name;
        let toolArgs: Record<string, unknown> = {};

        try {
          toolArgs = JSON.parse(toolCall.function.arguments);
        } catch (e) {
          console.log(`Failed to parse tool arguments: ${e}`);
          toolArgs = { message: '', sentiment: 'neutral' };
        }

        console.log(`AI used tool: ${toolName}`);
        toolResult = processToolCall(toolName, toolArgs, character, player);
        responseContent = toolResult.message;
        sentiment = toolResult.sentiment;

        // Record tool use for cooldowns
        if (toolResult.recordForCooldown && toolMetadata[toolName]?.cooldownHours) {
          const gameHour = (player.dayOfYear ?? 1) * 24 + (player.hourOfDay ?? 0);
          recordToolUse(toolName, character.id, player.toolCooldowns, gameHour);
        }

        // Queue pending event if tool generated one
        if (toolResult.pendingEvent) {
          addPendingEvent(player, toolResult.pendingEvent);
          console.log(`Queued pending event: ${toolResult.pendingEvent.type}`);
        }

        // Queue announcement if tool has one (for popup/notification display)
        if (toolResult.announcement) {
          const announcement = {
            id: `ann_${character.id}_${Date.now()}`,
            characterId: character.id,
            characterName: character.firstname,
            title: toolResult.announcement.title,
            message: toolResult.announcement.message,
            image: toolResult.announcement.image,
            category: toolResult.announcement.category,
            createdAt: new Date().toISOString(),
          };
          player.pendingAnnouncements.push(announcement);
          console.log(`Queued announcement: ${announcement.title}`);
        }

        // Apply player stat changes
        if (toolResult.playerStatChanges) {
          const c = player.c;
          for (const [stat, change] of Object.entries(toolResult.playerStatChanges)) {
            if (stat in c && typeof (c as any)[stat] === 'number') {
              (c as any)[stat] = Math.max(0, Math.min(100, ((c as any)[stat] ?? 50) + change));
            }
          }
        }

        // Apply familiarity change
        if (toolResult.familiarityChange) {
          character.familiarity = (character.familiarity ?? 0) + toolResult.familiarityChange;
        }

      } else {
        // Parse JSON response (non-tool-calling mode or fallback)
        const rawContent = message?.content ?? '';

        console.log(`Raw AI response content (${rawContent.length} chars): ${rawContent.substring(0, 200)}${rawContent.length > 200 ? '...' : ''}`);

        // First, clean up any embedded function call syntax that Qwen3 sometimes outputs as text
        let cleanedContent = rawContent;

        // Remove embedded tool call JSON from content (Qwen3 sometimes outputs this as text)
        // Pattern: message text followed by {"function": "send_message", ...}
        cleanedContent = cleanedContent.replace(/\s*\{?\s*"function"\s*:\s*"[^"]+"\s*,\s*"parameters"\s*:\s*\{[^}]*\}\s*\}?\s*$/gi, '').trim();

        // Pattern: "send_message", "parameters": {...}
        cleanedContent = cleanedContent.replace(/\s*"?send_message"?\s*,\s*"parameters"\s*:\s*\{[^}]*\}\s*\}?\s*$/gi, '').trim();

        // Pattern: starts with partial function call
        cleanedContent = cleanedContent.replace(/^\s*"?function"?\s*:\s*"send_message"\s*,\s*/gi, '').trim();

        try {
          const parsed = JSON.parse(cleanedContent);
          responseContent = parsed.message ?? '';
          // Strip <think> tags from Qwen3 thinking mode (if present)
          responseContent = responseContent.replace(/<think>[\s\S]*?<\/think>/gi, '').trim();
          responseContent = responseContent.replace(/<think>[\s\S]*/gi, '').trim();
          sentiment = ['positive', 'negative', 'neutral'].includes(parsed.sentiment)
            ? parsed.sentiment
            : 'neutral';
          if (typeof parsed.affinityDelta === 'number') {
            affinityDelta = parsed.affinityDelta;
          }
          console.log(`Parsed JSON response - Sentiment: ${sentiment}, AffinityDelta: ${affinityDelta ?? 'N/A'}, Message: ${responseContent.substring(0, 50)}...`);
        } catch (e) {
          console.log(`JSON parse failed: ${e}. Attempting to extract message from raw response.`);
          responseContent = cleanedContent; // Use the pre-cleaned content

          // Strip <think> tags from Qwen3 thinking mode (if present)
          responseContent = responseContent.replace(/<think>[\s\S]*?<\/think>/gi, '').trim();
          responseContent = responseContent.replace(/<think>[\s\S]*/gi, '').trim();

          // Remove any remaining embedded function call syntax
          responseContent = responseContent.replace(/\s*\{?\s*"function"\s*:\s*"[^"]+"\s*,\s*"parameters"\s*:\s*\{[^}]*\}\s*\}?\s*/gi, '').trim();
          responseContent = responseContent.replace(/\s*"?send_message"?\s*,\s*"parameters"\s*:\s*\{[^}]*\}\s*\}?\s*/gi, '').trim();

          // Try to extract message from partial/malformed JSON
          // Pattern: {"message": "actual message", ...} or just "message": "..."
          const jsonMessageMatch = responseContent.match(/"message"\s*:\s*"((?:[^"\\]|\\.)*)"/i);
          if (jsonMessageMatch && jsonMessageMatch[1]) {
            responseContent = jsonMessageMatch[1];
            console.log(`Extracted message from partial JSON: ${responseContent.substring(0, 50)}...`);

            // Also try to extract sentiment
            const jsonSentimentMatch = rawContent.match(/"sentiment"\s*:\s*"(positive|negative|neutral)"/i);
            if (jsonSentimentMatch && jsonSentimentMatch[1]) {
              sentiment = jsonSentimentMatch[1] as 'positive' | 'negative' | 'neutral';
            }
          } else {
            // Clean up any remaining JSON artifacts
            responseContent = responseContent
              .replace(/^\s*\{?\s*"message"\s*:\s*"?/i, '')  // Remove opening JSON
              .replace(/",?\s*"sentiment"\s*:\s*"[^"]*"?\s*,?/gi, '')  // Remove sentiment field
              .replace(/",?\s*"mood"\s*:\s*"[^"]*"?\s*,?/gi, '')  // Remove mood field
              .replace(/"\s*\}?\s*$/i, '')  // Remove closing JSON
              .trim();
          }

          // Fallback: try regex extraction for backwards compatibility
          const sentimentPatterns = [
            { regex: /<<\s*positive\s*>>/gi, value: 'positive' as const },
            { regex: /<<\s*negative\s*>>/gi, value: 'negative' as const },
            { regex: /<<\s*neutral\s*>>/gi, value: 'neutral' as const },
          ];
          for (const { regex, value } of sentimentPatterns) {
            if (regex.test(responseContent)) {
              sentiment = value;
              responseContent = responseContent.replace(regex, '').trim();
              break;
            }
          }
        }
      }

      // Final comprehensive JSON cleanup pass
      // This catches any remaining JSON/function call syntax that slipped through
      responseContent = cleanJsonFromMessage(responseContent);

      // Check for undesired AI-revealing responses
      const undesiredPhrases = [
        "Sorry, I can't",
        "Sorry, I cannot",
        "sorry, I can't",
        "sorry, I cannot",
        'Sorry,',
        'language model',
        ' AI ',
        'AI,',
      ];
      if (undesiredPhrases.some((phrase) => responseContent.includes(phrase))) {
        console.log('Undesired response detected, retrying...');
        retryCount++;
        continue;
      }

      // Apply affinity effects using dynamic delta when available
      // Affinity is bounded -100..100 (STAT_BOUNDS.affinity), consistent with the
      // global fix and the offline GameEngine mirror — so a hostile/negative
      // conversation can push affinity below 0 and that negative feeling persists,
      // instead of being floored at 0 here. (player.c.social, a normal 0..100 stat,
      // stays 0..100.)
      if (toolResult?.affinityChange !== undefined) {
        // Tool already applied affinity change — clamp to -50/+30 range
        const clampedDelta = Math.max(-50, Math.min(30, toolResult.affinityChange));
        character.affinity = Math.max(-100, Math.min(100, (character.affinity ?? 50) + clampedDelta));
        console.log(`Tool applied affinity change: ${clampedDelta > 0 ? '+' : ''}${clampedDelta}`);
      } else if (typeof affinityDelta === 'number') {
        // AI returned a dynamic affinity delta — clamp to -50/+30 and apply
        const clampedDelta = Math.max(-50, Math.min(30, affinityDelta));
        character.affinity = Math.max(-100, Math.min(100, (character.affinity ?? 50) + clampedDelta));
        console.log(`AI affinity delta: ${clampedDelta > 0 ? '+' : ''}${clampedDelta} → affinity now ${character.affinity}`);
      } else {
        // Fallback: sentiment-based affinity
        if (sentiment === 'positive') {
          character.affinity = Math.min(100, (character.affinity ?? 50) + 5);
        } else if (sentiment === 'negative') {
          character.affinity = Math.max(-100, (character.affinity ?? 50) - 5);
        } else {
          player.c.social = Math.min(100, (player.c.social ?? 50) + 1);
        }
      }

      // A warm conversation counts as a positive interaction: stamp the contact
      // date (read by the NPC time-gap initiative) and, for positive sentiment,
      // reset the romantic neglect clock so chatting your partner up keeps the
      // relationship from drifting/auto-breaking.
      character.lastConversationDate = player.date;
      if (sentiment === 'positive') {
        recordPositiveInteraction(character, player.date);
      }

      // Update messaging modifiers based on conversation dynamics
      try {
        const relData = ensureRelationshipData(player, character);
        if (relData.messaging_modifiers) {
          updateConversationMessagingModifiers(
            relData,
            sentiment,
            conversation.conversation.length
          );
        }
      } catch (e) {
        // Messaging modifiers not critical, continue
      }

      // Extract facts periodically (every 10 new messages since last extraction)
      const messageCount = conversation.conversation.length;
      const lastExtractionIdx = conversation.last_fact_extraction_index ?? 0;
      const messagesSinceExtraction = messageCount - lastExtractionIdx;

      if (messagesSinceExtraction >= 10) {
        try {
          const memory = new CharacterMemory(character.id, player.userId);
          // Extract from the full batch since last extraction, not just last 5
          const messagesToExtract = conversation.conversation.slice(lastExtractionIdx, messageCount);
          const extractedFacts = await memory.extractFactsFromMessages(
            messagesToExtract, character
          );
          if (extractedFacts.length > 0) {
            console.log(`Extracted ${extractedFacts.length} new facts for ${character.firstname} from ${messagesToExtract.length} messages`);
          }
          // Update extraction index regardless of whether facts were found
          conversation.last_fact_extraction_index = messageCount;
        } catch (e) {
          // Fact extraction not critical, continue
          console.log('Fact extraction failed:', e);
        }
      }

      console.log(`Final response content (${responseContent.length} chars): ${responseContent.substring(0, 100)}${responseContent.length > 100 ? '...' : ''}`);

      // Don't add messages that addMessage would reject
      if (!responseContent || responseContent.length < 1) {
        console.log(`WARNING: Response too short (${responseContent?.length ?? 0} chars)`);
        throw new Error('ai_empty_response');
      }

      // Check if AI echoed the user's last message (a known AI bug)
      const lastUserMessage = conversation.conversation
        .filter((m: { sender?: string }) => m.sender !== undefined && m.sender !== character.id && m.sender !== 'system')
        .pop();
      if (lastUserMessage && typeof lastUserMessage.message === 'string' &&
          responseContent.trim().toLowerCase() === lastUserMessage.message.trim().toLowerCase()) {
        console.log('WARNING: AI echoed user message');
        throw new Error('ai_echo_response');
      }

      // Add message to conversation with tool/mood data if available
      const messageData: Record<string, unknown> = {
        ...(toolResult?.messageData ?? {}),
      };

      // Add tool-specific metadata for UI rendering
      if (toolResult) {
        if (toolResult.mood) {
          messageData.mood = toolResult.mood;
        }

        // If a tool other than send_message was used, include tool info
        // This allows iOS to show special styling/indicators
        if (toolResult.pendingEvent) {
          messageData.toolUsed = toolResult.pendingEvent.type;
          messageData.pendingEventId = toolResult.pendingEvent.id;
          messageData.pendingEventType = toolResult.pendingEvent.type;

          // Create a human-readable preview of what's happening
          const eventPreviews: Record<string, string> = {
            activity_invite: `📅 ${character.firstname} wants to do an activity with you!`,
            date_request: `💕 ${character.firstname} is asking you on a date!`,
            emotional_moment: `💭 ${character.firstname} is sharing something important...`,
            favor_request: `🤝 ${character.firstname} is asking for your help`,
            gift_received: `🎁 ${character.firstname} gave you a gift!`,
            confession: `💗 ${character.firstname} has something important to tell you...`,
          };
          messageData.eventPreview = eventPreviews[toolResult.pendingEvent.type] ?? 'Something special is happening!';
          messageData.isSpecialEvent = true;
        }
      }

      // Compute the affinity delta for UI display (top-level on message)
      const appliedDelta = toolResult?.affinityChange
        ?? affinityDelta
        ?? (sentiment === 'positive' ? 5 : sentiment === 'negative' ? -5 : 0);
      const clampedAffinityDelta = Math.max(-50, Math.min(30, appliedDelta));

      conversation.addMessage(responseContent, undefined, {
        sentiment,
        affinityDelta: clampedAffinityDelta,
        date: player.date,
        time: player.time,
        data: Object.keys(messageData).length > 0 ? messageData : undefined,
      });

      return;
    } catch (error) {
      console.log(`${aiProvider.name} API error: ${error}`);
      retryCount++;
      if (retryCount < maxRetries) {
        await new Promise((resolve) => setTimeout(resolve, backoffDelays[retryCount - 1]));
      } else {
        console.log('Maximum retry attempts reached, using fallback response');
        await getFallbackResponse(conversation, character, player);
        return;
      }
    }
  }

  // Should not reach here, but use fallback if we do
  console.log('Unexpected exit from retry loop, using fallback response');
  await getFallbackResponse(conversation, character, player);
}

/**
 * Send a character-initiated message
 */
export async function sendCharacterMessage(
  player: Player,
  character: Person
): Promise<void> {
  console.log('Sending character message');

  const characterId = character.id;

  // Find existing conversation or create new one
  let conversation = player.conversations.find(
    (conv) => {
      const convChar = typeof conv.character === 'string' ? conv.character : conv.character?.id;
      return convChar === characterId;
    }
  ) as ConversationObj | undefined;

  if (!conversation) {
    conversation = new ConversationObj(characterId, 'chat');
    player.conversations.push(conversation);
  }

  // Build character description
  const characterDescription = generateOpenAIDescription(character);
  const playerDescription = generatePersonDescription(player.c);
  const affinityVal = character.affinity ?? 50;
  const affinityDescription =
    affinityVal > 60
      ? 'positive'
      : affinityVal < 30
        ? 'negative'
        : 'neutral';
  const familiarityDescription =
    (character.familiarity ?? 0) > 50
      ? 'close'
      : (character.familiarity ?? 0) >= 25
        ? 'casual'
        : 'distant';
  const characterRelationship = character.relationships?.[0] ?? 'acquaintance';

  const promptTemplate = `Talk like a human would on a messaging app. Phrase things casually, keeping your responses under 150 characters. Don't start messages with greetings like Hey, Hi, or Hello frequently. Maintain a ${affinityDescription} attitude towards the player, with whom you have a ${familiarityDescription} relationship. Never reveal that you are an AI. If the player is rude, be rude back. If the player is inappropriate, tell them they'll be reported.

${AFFINITY_SCORING_INSTRUCTIONS}

${characterDescription}. You are talking to: ${playerDescription}. Remember: you are a human, never break character. Message them as a cold message, you are not responding to anything. This is the first message in the conversation. Send a fun, unique message that will make them want to respond. Make sure your message is fitting for your relationship as a '${characterRelationship}' of the player.`;

  const messageList: Array<{ role: 'system' | 'user' | 'assistant'; content: string }> = [];
  const recentMessages = conversation.conversation.slice(-10);

  for (const message of recentMessages) {
    const role: 'user' | 'assistant' = message.sender === player.c.id ? 'user' : 'assistant';
    messageList.push({ role, content: message.message.trim() });
  }

  messageList.unshift({ role: 'system', content: promptTemplate });

  try {
    const tokens = 75 + Math.floor(Math.random() * 100);
    const thinkBuf = getThinkingBuffer(tokens);

    console.log(`Calling ${aiProvider.name} API for character message (model: ${aiProvider.model})`);

    const result = await Promise.race([
      aiProvider.client.chat.completions.create({
        model: aiProvider.model,
        messages: messageList,
        max_tokens: tokens + thinkBuf,
        temperature: 0.8,
        frequency_penalty: 0.6,
        presence_penalty: 0.5,
        response_format: {
          type: "json_object" as const,
        }
      }),
      new Promise<never>((_, reject) =>
        setTimeout(() => reject(new Error('Timeout')), 15000)
      ),
    ]);

    const rawContent = result.choices[0]?.message?.content ?? '';

    // Parse JSON response
    let responseContent = '';
    let sentiment: 'positive' | 'negative' | 'neutral' = 'neutral';
    try {
      const parsed = JSON.parse(rawContent);
      responseContent = parsed.message ?? '';
      sentiment = ['positive', 'negative', 'neutral'].includes(parsed.sentiment)
        ? parsed.sentiment
        : 'neutral';
      console.log(`Parsed JSON response (character message) - Sentiment: ${sentiment}`);
    } catch (e) {
      console.log('JSON parse failed, using raw response and extracting sentiment via regex fallback');
      responseContent = rawContent;
      // Fallback: try regex extraction for backwards compatibility
      const sentimentPatterns = [
        { regex: /<<\s*positive\s*>>/gi, value: 'positive' as const },
        { regex: /<<\s*negative\s*>>/gi, value: 'negative' as const },
        { regex: /<<\s*neutral\s*>>/gi, value: 'neutral' as const },
      ];
      for (const { regex, value } of sentimentPatterns) {
        if (regex.test(responseContent)) {
          sentiment = value;
          responseContent = responseContent.replace(regex, '').trim();
          break;
        }
      }
    }
    conversation.addMessage(responseContent, undefined, {
      sentiment,
      affinityDelta: sentiment === 'positive' ? 5 : sentiment === 'negative' ? -5 : 0,
      date: player.date,
      time: player.time,
    });
  } catch (error) {
    console.log(`${aiProvider.name} API call error: ${error}`);
  }
}
