/**
 * Conversation Handlers
 * Handles chat conversations with NPCs
 */

import type { PlayerSession } from '../game/PlayerSession.js';
import { conversationInit } from '../events/conversations/events.js';
import { ConversationObj, getOpenAIResponse } from '../events/conversations/index.js';
import { getCharacterAvailability } from '../events/conversations/availability.js';
import { saveConversation, markConversationAsRead } from '../database/players.js';
import { resolvePayloadId } from './payloadHelpers.js';
import {
  updateQuestProgress,
  trackConversation,
  sendQuestProgress,
} from '../services/retention/index.js';

// Tracks pending AI response generation per conversation
interface PendingAICall {
  timer: ReturnType<typeof setTimeout> | null;
  inFlight: boolean; // true while getOpenAIResponse is running
}

const pendingAICalls = new Map<string, PendingAICall>();

const DEBOUNCE_MS = 2000; // 2 seconds — double-text window

// iOS sends: { characterID, response, conversationEvent, cType }
// Note: characterID has capital ID to match iOS app
interface ConversationPayload {
  characterID: string;  // iOS uses capital ID
  response?: string;    // iOS uses 'response' not 'message'
  conversationEvent?: string;  // 'response' | 'freeResponse' | etc.
  cType?: string;
  tempId?: string;      // Client-generated ID for message reconciliation
}

interface MarkReadPayload {
  conversationId: string;
}

/** Relationship tags that indicate a romantic connection (dating app match, partner, etc.). */
const ROMANTIC_RELATIONSHIP_TAGS = new Set([
  'dating_match',
  'dating',
  'partner',
  'girlfriend',
  'boyfriend',
  'spouse',
  'wife',
  'husband',
  'fiancé',
  'fiancée',
  'engaged',
]);

/**
 * Detect a brand-new "first contact" conversation and return extra prompt context
 * so the AI opens like two people who just met — rather than assuming shared history.
 *
 * This fixes the first-chat bug where a freshly matched NPC (e.g. from swipe dating)
 * talked as if they were already a longtime partner: ai_response.ts classifies any
 * romantic tag as an established relationship and instructs the model to use pet names,
 * reference intimacy, etc. For a just-matched person that's jarring.
 *
 * Signal: the NPC has not sent a single message in this conversation yet AND the player
 * barely knows them (low familiarity). That covers a swipe match (familiarity 0, partner
 * tag) and any newly-met NPC, while excluding established relationships even on a fresh
 * conversation thread. Returns undefined when this isn't a first-contact situation.
 */
function buildFirstContactContext(
  convo: ConversationObj,
  character: { firstname?: string; familiarity?: number; relationships?: string[] }
): string | undefined {
  const messages = Array.isArray(convo.conversation) ? convo.conversation : [];
  const characterId =
    typeof convo.character === 'string' ? convo.character : convo.character?.id;

  // If the NPC has already spoken in this thread, it isn't first contact.
  const npcHasSpoken = messages.some(
    (m) => m.sender !== undefined && m.sender !== 'system' && m.sender === characterId
  );
  if (npcHasSpoken) return undefined;

  // Only treat as "just met" when familiarity is low. Established relationships
  // (high familiarity) shouldn't get the strangers-meeting framing.
  const familiarity = character.familiarity ?? 0;
  if (familiarity >= 25) return undefined;

  const name = character.firstname ?? 'They';
  const isRomantic = (character.relationships ?? []).some((r) =>
    ROMANTIC_RELATIONSHIP_TAGS.has(r.toLowerCase())
  );

  if (isRomantic) {
    return `FIRST CONTACT — NEW DATING-APP MATCH: You and the player just matched on a dating app and this is your very first message exchange. You do NOT know each other yet — you have never met in person, gone on a date, or talked before. Do NOT reference shared history, inside jokes, past dates, or established intimacy, and do NOT use pet names or act like a longtime partner. This is the exciting-but-unfamiliar start of getting to know someone: be warm, lightly flirty and curious, but keep it grounded in the fact that you're strangers who just connected. A natural opener might react to matching, ask an easy getting-to-know-you question, or comment on something from their profile.`;
  }

  return `FIRST CONTACT — YOU JUST MET: This is the very first message between you and the player, and you barely know each other. Do NOT reference shared history, past conversations, or a close relationship that hasn't been established yet. Keep it natural for two people just starting to talk — friendly and curious, getting to know them.`;
}

export async function handleConversation(
  payload: unknown,
  session: PlayerSession
): Promise<void> {
  // iOS sends: { characterID, response, conversationEvent, cType }
  const { characterID, response, conversationEvent, cType, tempId } = payload as ConversationPayload;
  const normalizedResponse = typeof response === 'string' ? response.trim() : undefined;
  const player = session.player;

  // Find the character in player's relationships
  const character = player.r.find((p) => p.id === characterID);

  if (!character) {
    console.log('Character not found in relationships. Available IDs:', player.r.map(p => p.id));
    session.send({ type: 'conversationError', message: 'Character not found', tempId });
    return;
  }

  // Find or create conversation
  let convoData = player.conversations.find(
    (c) => c.character === characterID || (c.character as any)?.id === characterID
  );

  let convo: ConversationObj;
  if (!convoData) {
    convo = new ConversationObj(character, cType ?? 'chat');
    player.conversations.push(convo);
  } else if (convoData instanceof ConversationObj) {
    convo = convoData;
  } else {
    // Wrap plain object in ConversationObj class to get methods
    convo = ConversationObj.fromData(convoData as unknown as Record<string, unknown>, character);
    // Replace in array with proper instance
    const idx = player.conversations.indexOf(convoData);
    if (idx >= 0) player.conversations[idx] = convo;
  }

  // Add player's message if provided (for response/freeResponse)
  if (normalizedResponse) {
    convo.addMessage(normalizedResponse, player.c.id, {
      date: player.date,
      time: player.time,
      tempId: tempId,
    });
  }

  // Player is actively viewing this conversation — mark as read
  convo.unread = false;
  void markConversationAsRead(convo.id, player.userId)
    .catch(err => console.error('Error marking opened conversation as read:', err));

  // Send conversation state to client immediately
  session.send(convo.toJSON());

  // Send character object like Python does
  session.send({
    type: 'personObject',
    ...(typeof character.toJSON === 'function' ? character.toJSON() : character),
  });

  // Only generate AI response if player sent a message (not just loading conversation)
  if (normalizedResponse) {
    // Check if the character would realistically respond at this hour
    const availability = getCharacterAvailability(
      player.hourOfDay,
      character.relationships ?? [],
      character.affinity ?? 50,
      character.firstname ?? 'They',
    );

    if (!availability.available) {
      // Character is unavailable — send delivery notice, skip AI call
      console.log(`[Availability] ${character.firstname} unavailable at hour ${player.hourOfDay}`);
      convo.addMessage(availability.message!, 'system', {
        date: player.date,
        time: player.time,
      });
      session.send(convo.toJSON());
      await saveConversation(
        player.userId,
        characterID,
        convo.id,
        Array.isArray(convo.conversation) ? convo.conversation : [],
        convo.unread ?? false
      ).catch(err => console.error('Error saving unavailable conversation:', err));
      return;
    }

    const key = `${player.userId}:${characterID}`;

    // Detect a brand-new match / first-contact conversation so the AI doesn't
    // assume a pre-existing relationship in its opening reply (first-chat bug).
    const firstContactContext = buildFirstContactContext(convo, character);

    // Cancel any pending debounce timer (player sent another message)
    const existing = pendingAICalls.get(key);
    if (existing?.timer) {
      clearTimeout(existing.timer);
    }

    // Set debounce timer — waits for player to finish sending messages
    const timer = setTimeout(() => {
      // Mark as in-flight, clear timer ref
      pendingAICalls.set(key, { timer: null, inFlight: true });

      // Notify client that NPC is now "typing"
      session.send({ type: 'npcTyping', characterID });

      // Generate AI response in background
      generateAIResponse(session, convo, character, player, characterID, tempId, firstContactContext)
        .finally(() => pendingAICalls.delete(key));
    }, DEBOUNCE_MS);

    pendingAICalls.set(key, { timer, inFlight: false });
  } else {
    console.log('Load-only request, skipping AI response generation');
  }
}

async function generateAIResponse(
  session: PlayerSession,
  convo: ConversationObj,
  character: any,
  player: any,
  characterID: string,
  tempId: string | undefined,
  customPrompt?: string,
): Promise<void> {
  try {
    console.log('Generating AI response for conversation...');
    await getOpenAIResponse(convo, character, player, customPrompt);
    console.log('AI response generated, last message:', convo.conversation[convo.conversation.length - 1]);

    // Persist conversation to database
    const messages = Array.isArray(convo.conversation) ? convo.conversation : [];
    await saveConversation(
      player.userId,
      characterID,
      convo.id,
      messages,
      convo.unread ?? false
    ).catch(err => console.error('Error saving conversation:', err));

    // Send updated conversation with AI response if the phone is still connected.
    if (session.isWSOpen) {
      session.send(convo.toJSON());
    } else if (process.env.NODE_ENV !== 'test') {
      console.log(`AI response persisted for ${player.userId}, but WebSocket is closed`);
    }

    // Track conversation statistics and update quest progress
    trackConversation(player.userId);
    const quest = await updateQuestProgress(player.userId, 'talk_to_characters', 1, player);
    if (quest) {
      sendQuestProgress(session, quest);
    }
  } catch (err: any) {
    const errorCode = err?.message ?? 'unknown';
    console.error('Error generating AI response:', errorCode);

    const userMessages: Record<string, string> = {
      rate_limited: 'Too many messages. Please wait a moment before sending another.',
      ai_empty_response: 'Failed to get a response. Please try again.',
      ai_echo_response: 'Failed to get a response. Please try again.',
      ai_max_retries: 'Failed to get a response. Please try again.',
      ai_unexpected_exit: 'Failed to get a response. Please try again.',
    };

    if (session.isWSOpen) {
      session.send({
        type: 'conversationError',
        message: userMessages[errorCode] ?? 'Failed to get a response. Please try again.',
        tempId,
      });
    }
  }
}

export async function handleRetrievePerson(
  payload: unknown,
  session: PlayerSession
): Promise<void> {
  // iOS sends just the person ID as a string directly (not wrapped in object)
  const personId = resolvePayloadId(payload, 'personId');
  const player = session.player;

  // Find the person in relationships
  const person = player.r.find((p) => p.id === personId);

  if (!person) {
    console.log('Person not found. Available IDs:', player.r.map(p => p.id));
    session.send({
      type: 'error',
      message: 'Person not found',
    });
    return;
  }

  // Spread person data at root level like Python does
  session.send({
    type: 'personObject',
    ...(typeof person.toJSON === 'function' ? person.toJSON() : person),
  });
}

export async function handleMarkRead(payload: unknown, session: PlayerSession): Promise<void> {
  const { conversationId } = payload as MarkReadPayload;
  const player = session.player;

  // Find and mark conversation as read
  const convo = player.conversations.find((c) => c.id === conversationId);

  if (convo) {
    convo.unread = false;

    // Persist to database
    await markConversationAsRead(conversationId, player.userId)
      .catch(err => console.error('Error marking conversation as read:', err));

    session.send({
      type: 'conversationMarkedRead',
      conversationId,
      success: true,
    });
  } else {
    session.send({
      type: 'error',
      message: 'Conversation not found',
    });
  }
}
