/**
 * Restaurant Chat Agent Service
 * Isolated AI service module — swap provider by changing the import/call here only.
 * Handles: food orders, table bookings, menu questions, escalation detection.
 * KB is exposed as a search_knowledge_base function tool (semantic search via pgvector).
 * Orders and bookings are placed via place_order and book_table tools.
 */

import { searchKnowledgeBase, formatSearchResults } from '@server/services/kb-embeddings.service';
import { createOrder } from '@server/services/orders.service';
import { createBooking } from '@server/services/bookings.service';
import { ConflictError, ValidationError } from '@server/errors';
import { childLogger } from '@server/logger';
const log = childLogger('ai.restaurant-chat');

import {
  validateOrderItemsForAI,
  buildRejectionMessage,
  buildRejectionPayload,
  logAIOrderRejections,
  type RejectionPayload,
} from '@server/services/menu.service';
import { createNotification } from '@server/services/notifications.service';
import { enqueueEmail } from '@server/services/email/outbox.service';
import { formatOrderItemsHtml, formatMoney, formatOrderSummaryHtml, formatBookingSummaryHtml } from '@server/services/email/format';
import { resolveRestaurantOwnerEmail, getRestaurantNotificationMode } from '@server/services/email/recipients';
import { TAX_RATE, MAX_COMPLETION_TOKENS, DEFAULT_LLM_MODEL } from '@server/config/ai-defaults';

export interface SendAgentOptions {
  /** UUID of the persisted ai_agents row (used for KB scoping & order/booking attribution). */
  agentId?: string | null;
  /** Optional explicit menu_category_ids whitelist used to restrict which items the agent can see/order. */
  menuCategoryIds?: string[] | null;
  /** Per-agent capability flags — used to gate which OpenAI tools are exposed. */
  capabilities?: Record<string, boolean> | null;
  /** Optional override list of KB ids to scope search_knowledge_base. Used by
   * the Test Agent endpoint so an UNSAVED draft KB selection works without
   * writing to agent_knowledge_bases. */
  knowledgeBaseIds?: string[] | null;
}

export interface AgentConfig {
  model: string;
  systemPrompt?: string;
  responseStyle?: string;
  greetingScript?: string;
  closingScript?: string;
  fallbackRules?: Array<{ trigger: string; action: string; priority: string }>;
}

export interface MenuContext {
  categories?: string[];
  items?: Array<{ id?: string; name: string; price?: number; description?: string; category?: string }>;
  contactInfo?: string;
}

export interface ChatMessage {
  role: 'system' | 'user' | 'assistant';
  content: string;
}

export interface OrderResult {
  id?: string;
  customer_name?: string;
  [key: string]: unknown;
}

export interface BookingResult {
  id?: string;
  guest_name?: string;
  [key: string]: unknown;
}

export interface AgentResponse {
  content: string;
  shouldEscalate: boolean;
  escalationReason?: string;
  detectedIntent?: 'order' | 'booking' | 'menu_query' | 'general' | 'complaint' | 'escalation';
  extractedData?: Record<string, unknown>;
  orderCreated?: OrderResult;
  bookingCreated?: BookingResult;
  suggestedItems?: Array<{ name: string; price: number; description?: string }>;
  /** Structured rejection payload when place_order was blocked by validation. */
  orderRejections?: RejectionPayload;
}

function styleToTone(style?: string): string {
  switch (style) {
    case 'professional': return 'Maintain a professional and courteous tone.';
    case 'concise': return 'Be concise and direct. Keep responses brief.';
    default: return 'Be warm, friendly, and conversational.';
  }
}

function buildMenuContext(menu: MenuContext): string {
  if (!menu.items?.length && !menu.categories?.length) return '';
  const lines: string[] = ['--- Menu ---'];
  if (menu.categories?.length) {
    // Drop categories that have zero orderable items in the supplied list so
    // the model never sees a category line that has nothing it can sell from.
    // Falls back to the supplied list when items don't carry category info.
    const itemCats = new Set(
      (menu.items ?? [])
        .map(i => (i.category ?? '').toLowerCase())
        .filter(Boolean)
    );
    const visible = itemCats.size > 0
      ? menu.categories.filter(c => itemCats.has(c.toLowerCase()))
      : menu.categories;
    if (visible.length > 0) {
      lines.push(`Categories: ${visible.join(', ')}`);
    }
  }
  if (menu.items?.length) {
    menu.items.slice(0, 50).forEach(item => {
      const price = item.price ? ` - $${Number(item.price).toFixed(2)}` : '';
      const desc = item.description ? ` (${item.description.slice(0, 80)})` : '';
      lines.push(`• ${item.name}${price}${desc}`);
    });
  }
  lines.push('--- End Menu ---');
  return lines.join('\n');
}

function buildFallbackContext(rules: AgentConfig['fallbackRules']): string {
  if (!rules?.length) return '';
  const lines = ['--- Escalation Rules (follow these strictly) ---'];
  rules.forEach(r => {
    lines.push(`If "${r.trigger}" → ${r.action} [Priority: ${r.priority}]`);
  });
  lines.push('--- End Escalation Rules ---');
  return lines.join('\n');
}

export type AgentChannel = 'widget' | 'whatsapp' | 'sms' | 'voice';

export function buildSystemPrompt(
  config: AgentConfig,
  restaurantName: string,
  menu: MenuContext,
  channel: AgentChannel = 'widget'
): string {
  const tone = styleToTone(config.responseStyle);
  const menuContext = buildMenuContext(menu);
  const fallbackContext = buildFallbackContext(config.fallbackRules);

  const basePrompt = config.systemPrompt || `You are an AI assistant for ${restaurantName}. You help customers with food orders, table bookings, menu questions, and general inquiries.`;

  const contactSection = menu.contactInfo
    ? `\n\nRESTAURANT CONTACT & HOURS (use this directly — do NOT search the knowledge base for this information):\n${menu.contactInfo}`
    : '';

  const loyaltySection = channel === 'widget'
    ? `\nLOYALTY POINTS: Loyalty points redemption is not available through the chat widget. If a customer asks to redeem points, politely inform them that point redemption must be done through the restaurant's app or at the counter.\n`
    : `\nLOYALTY POINTS REDEMPTION:
- If the customer asks to use, redeem, or apply loyalty points, you MUST call the preview_loyalty_redeem tool BEFORE place_order — never guess the discount.
- Pass the customer's phone, the order subtotal, and the points they want to redeem. Read back the resulting discount and clamped point count, and ask the customer to confirm.
- Only after the customer confirms, call place_order with points_to_redeem set to the allowed_points returned by the preview.
- If preview returns ok:false (no wallet, no phone, etc.), tell the customer points cannot be applied and offer to place the order without redemption.\n`;

  return `${basePrompt}

${tone}

CAPABILITIES:
1. FOOD ORDERS: Help customers browse the menu, add items, customize orders. When the customer confirms their order, use the place_order tool to create the order. Valid delivery types: dine-in, takeaway, delivery. customer_name is optional (default "Widget Customer").
2. TABLE BOOKINGS: Assist with reservation requests. When the customer confirms booking details, use the book_table tool immediately with all provided information.
3. MENU QUESTIONS: Answer questions about dishes, ingredients, allergens, prices, and availability using the menu below.
4. GENERAL INQUIRIES: For questions about working hours, address, phone number, or location — answer directly using the RESTAURANT CONTACT & HOURS section in this prompt if it is present. For other restaurant details (policies, events, specials, etc.) use the search_knowledge_base tool.
5. ESCALATION: If you cannot help, if the customer is upset, or if a fallback rule is triggered, respond with the exact phrase: [ESCALATE: <reason>] at the end of your message.

ORDERING WORKFLOW:
IMPORTANT — WIDGET PRE-CONFIRMED ORDERS: When the customer message contains the phrase "Please process this order now using the place_order tool", ALL details (items, delivery type, customer name, phone, and optionally email) are ALREADY collected and confirmed by the widget UI. You MUST call place_order immediately using the values from the message. Items are listed as "Nx ItemName @$price" — extract name, quantity (N), and price from each entry. If "Table number: X" appears in the message, include it as table_number. If "Delivery address: X" appears in the message, include it as delivery_address (required for delivery orders). If "Email: X" appears in the message, include it as customer_email. Do NOT ask for any information. Do NOT re-confirm. Simply call place_order and report the order number.

For conversational orders (no trigger phrase): help the customer choose items, then ask for their delivery preference (dine-in/takeaway/delivery), and place the order once confirmed.
${loyaltySection}
BOOKING WORKFLOW:
IMPORTANT — WIDGET PRE-CONFIRMED BOOKINGS: When the customer message contains the phrase "Please confirm and use the book_table tool", ALL details (date, time, party size, name, phone, and optionally email) are ALREADY collected and confirmed by the widget UI. You MUST call book_table immediately using the values from the message (use the ISO date/time values in parentheses, e.g. "ISO: 2026-03-29" → booking_date = "2026-03-29", "ISO: 19:00" → booking_time = "19:00"). If "Email: X" appears in the message, include it as guest_email. Do NOT ask for any information again. Do NOT ask for occasion or special requests. Simply call book_table and report the booking reference.

For conversational bookings (no trigger phrase): collect guest name, date (YYYY-MM-DD), time (HH:MM 24-hr), and party size, then call book_table.

${menuContext}${contactSection}

${fallbackContext}

ITEM CARDS:
- When you recommend or list specific dishes the customer asked about, always call the suggest_items tool with those items. This displays interactive "Add to Cart" cards the customer can tap to quickly add items to their basket.
- Pass items with their exact name, price, and a brief description from the menu above. Do not invent items.
- Continue providing a brief conversational text reply as normal alongside calling suggest_items.

IMPORTANT RULES:
- Use the search_knowledge_base tool for topics not already covered in this prompt (e.g. policies, events, specials). Do NOT use it for working hours, address, or phone — those are in RESTAURANT CONTACT & HOURS above.
- Never make up menu items or prices not listed above.
- If asked about something not in your knowledge base, say you'll connect them with a team member.
- Keep responses focused and helpful. Do not discuss topics unrelated to the restaurant.
- If the customer explicitly asks to speak to a human, always escalate: [ESCALATE: Customer requested human agent]

RESPONSE FORMAT:
- Keep every reply to 1-2 sentences maximum.
${channel === 'whatsapp'
  ? `- This conversation is on WhatsApp. Reply in plain text only. Do NOT include any JSON, markdown tables, or trailing structured data — anything other than human-readable text will be shown verbatim to the customer.
- Use line breaks (max 2-3 lines) and natural language only.`
  : `- On the very last line of your response, always include this JSON object (no other text after it): {"quickReplies":["Option A","Option B"]}
- Choose 2-3 short (2-5 words each) and contextually relevant quick-reply suggestions.
- Examples: after greeting → {"quickReplies":["🍽️ See our menu","📅 Book a table","❓ Ask a question"]}
- After order placed → {"quickReplies":["Add more items","That's all, thanks"]}
- After booking confirmed → {"quickReplies":["Modify booking","Order food","Done"]}
- During order → {"quickReplies":["Place my order","Add more items","Remove an item"]}
- During booking → {"quickReplies":["Continue booking","Start over","Ask a question"]}`}`;
}

export function detectEscalation(content: string): { shouldEscalate: boolean; reason?: string } {
  const match = content.match(/\[ESCALATE:\s*([^\]]+)\]/i);
  if (match) {
    return { shouldEscalate: true, reason: match[1].trim() };
  }
  return { shouldEscalate: false };
}

export function detectIntent(userMessage: string): AgentResponse['detectedIntent'] {
  const msg = userMessage.toLowerCase();
  if (/order|add to cart|i want|i'd like|can i get|give me|burger|pizza|pasta|salad|drink|food/i.test(msg)) return 'order';
  if (/book|reserve|reservation|table|seat|tonight|tomorrow|party of|people/i.test(msg)) return 'booking';
  if (/menu|price|cost|how much|ingredient|allergen|vegan|gluten|spicy|contain/i.test(msg)) return 'menu_query';
  if (/complaint|unhappy|wrong|mistake|refund|cancel|terrible|awful/i.test(msg)) return 'complaint';
  if (/human|agent|manager|person|staff|speak to|talk to/i.test(msg)) return 'escalation';
  return 'general';
}

const SEARCH_KB_TOOL = {
  type: 'function',
  function: {
    name: 'search_knowledge_base',
    description: 'Search the restaurant knowledge base for information about hours, policies, menu details, FAQs, and other restaurant-specific information.',
    parameters: {
      type: 'object',
      properties: {
        query: {
          type: 'string',
          description: 'The search query to find relevant knowledge base entries.',
        },
      },
      required: ['query'],
    },
  },
};

const PLACE_ORDER_TOOL = {
  type: 'function',
  function: {
    name: 'place_order',
    description: 'Place a food order for the customer after they have confirmed the items, quantities, and delivery type. Call this only after the customer has explicitly confirmed the order.',
    parameters: {
      type: 'object',
      properties: {
        customer_name: { type: 'string', description: 'The customer\'s name' },
        customer_phone: { type: 'string', description: 'The customer\'s phone number (optional)' },
        customer_email: { type: 'string', description: 'The customer\'s email address for order confirmation (optional)' },
        items: {
          type: 'array',
          description: 'Array of ordered items',
          items: {
            type: 'object',
            properties: {
              name: { type: 'string', description: 'Item name' },
              quantity: { type: 'number', description: 'Quantity ordered' },
              price: { type: 'number', description: 'Unit price' },
              notes: { type: 'string', description: 'Special instructions for this item' },
            },
            required: ['name', 'quantity'],
          },
        },
        delivery_type: { type: 'string', enum: ['dine-in', 'takeaway', 'delivery'], description: 'How the customer wants their food' },
        table_number: { type: 'string', description: 'Table number for dine-in orders (optional)' },
        delivery_address: { type: 'string', description: 'Street/delivery address for delivery orders (required when delivery_type is "delivery")' },
        special_instructions: { type: 'string', description: 'Any special instructions for the whole order' },
        coupon_code: { type: 'string', description: 'Promo / discount code the customer wants to apply (optional). Pass exactly as the customer typed it.' },
        total: { type: 'number', description: 'Total order amount' },
        points_to_redeem: { type: 'number', description: 'Optional number of loyalty points the customer wants to redeem on this order. Only set when the customer has explicitly asked to redeem points; the server clamps to balance and minimums.' },
        gift_card_code: { type: 'string', description: 'Optional gift card code to apply to this order. Will be validated server-side; the resulting receipt will show the discount and remaining balance.' },
      },
      required: ['items', 'delivery_type'],
    },
  },
};

const BOOK_TABLE_TOOL = {
  type: 'function',
  function: {
    name: 'book_table',
    description: 'Create a table reservation for the customer after they have confirmed all booking details. Call this only after the customer has confirmed date, time, and party size.',
    parameters: {
      type: 'object',
      properties: {
        guest_name: { type: 'string', description: 'Name of the person making the reservation' },
        guest_phone: { type: 'string', description: 'Guest\'s phone number (optional)' },
        guest_email: { type: 'string', description: 'Guest\'s email (optional)' },
        booking_date: { type: 'string', description: 'Reservation date in YYYY-MM-DD format' },
        booking_time: { type: 'string', description: 'Reservation time in HH:MM format (24-hour)' },
        party_size: { type: 'number', description: 'Number of guests' },
        occasion: { type: 'string', description: 'Special occasion (birthday, anniversary, etc.) — optional' },
        special_requests: { type: 'string', description: 'Any special requests or dietary needs' },
      },
      required: ['guest_name', 'booking_date', 'booking_time', 'party_size'],
    },
  },
};

const SUGGEST_ITEMS_TOOL = {
  type: 'function',
  function: {
    name: 'suggest_items',
    description: 'Show specific menu items to the customer as interactive cards they can tap to add to their cart. Call this whenever you recommend or list specific dishes the customer asked about.',
    parameters: {
      type: 'object',
      properties: {
        items: {
          type: 'array',
          description: 'List of menu items to show as interactive add-to-cart cards',
          items: {
            type: 'object',
            properties: {
              name: { type: 'string', description: 'Item name exactly as it appears on the menu' },
              price: { type: 'number', description: 'Item price' },
              description: { type: 'string', description: 'Brief description of the item (optional)' },
            },
            required: ['name', 'price'],
          },
        },
      },
      required: ['items'],
    },
  },
};

const PREVIEW_LOYALTY_REDEEM_TOOL = {
  type: 'function',
  function: {
    name: 'preview_loyalty_redeem',
    description: 'Preview how many loyalty points can be redeemed on the current order BEFORE calling place_order. Returns balance, allowed points (server-clamped to wallet/min/max), and the resulting dollar discount. ALWAYS call this first whenever the customer asks to use points, then read back the discount and ask the customer to confirm before placing the order.',
    parameters: {
      type: 'object',
      properties: {
        customer_phone: { type: 'string', description: "Customer's phone number — used to look up their loyalty wallet." },
        order_subtotal: { type: 'number', description: 'Pre-tax subtotal of the items the customer wants to order.' },
        points_to_redeem: { type: 'number', description: 'Points the customer wants to apply. The server will clamp this to balance and configured min/max.' },
      },
      required: ['customer_phone', 'order_subtotal', 'points_to_redeem'],
    },
  },
};

const ALL_TOOLS = [SEARCH_KB_TOOL, PLACE_ORDER_TOOL, BOOK_TABLE_TOOL, SUGGEST_ITEMS_TOOL, PREVIEW_LOYALTY_REDEEM_TOOL];

async function callOpenAIWithTools(
  messages: object[],
  model: string,
  tools: object[],
  apiKey?: string
): Promise<any> {
  const key = apiKey || process.env.OPENAI_API_KEY;
  if (!key) throw new Error('OpenAI API key not configured');

  const response = await fetch('https://api.openai.com/v1/chat/completions', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${key}`,
    },
    body: JSON.stringify({
      model,
      messages,
      tools,
      tool_choice: 'auto',
      max_tokens: MAX_COMPLETION_TOKENS,
    }),
  });

  if (!response.ok) {
    const err = await response.text();
    throw new Error(`OpenAI API error: ${response.status} ${err}`);
  }

  return response.json();
}

function asStr(val: unknown, fallback = ''): string {
  return typeof val === 'string' ? val : fallback;
}

function asNum(val: unknown, fallback = 0): number {
  return typeof val === 'number' ? val : fallback;
}

interface OrderItem {
  price?: number;
  quantity?: number;
  [key: string]: unknown;
}

async function handleToolCall(
  toolName: string,
  toolArgs: Record<string, unknown>,
  restaurantId: string,
  conversationId?: string,
  branchId?: string,
  channel: AgentChannel = 'widget',
  customerPhoneFallback?: string,
  agentId?: string | null,
  menuCategoryIds?: string[] | null,
  knowledgeBaseIds?: string[] | null
): Promise<{ content: string; result?: OrderResult | BookingResult; rejections?: RejectionPayload }> {
  if (toolName === 'search_knowledge_base') {
    const results = await searchKnowledgeBase(restaurantId, asStr(toolArgs.query), 5, agentId ?? null, knowledgeBaseIds ?? null);
    return { content: formatSearchResults(results) };
  }

  if (toolName === 'preview_loyalty_redeem') {
    // Loyalty redemption is blocked on the public widget: an unauthenticated
    // caller knowing a victim's phone number is not proof-of-possession.
    if (channel === 'widget') {
      return {
        content: JSON.stringify({
          ok: false,
          reason: 'not_available',
          message: 'Loyalty points redemption is not available through the chat widget. Please redeem points through the restaurant app or at the counter.',
        }),
      };
    }
    const phone = asStr(toolArgs.customer_phone) || customerPhoneFallback || '';
    const subtotal = Math.max(0, asNum(toolArgs.order_subtotal));
    const requested = Math.max(0, Math.floor(asNum(toolArgs.points_to_redeem)));
    if (!phone) {
      return { content: JSON.stringify({ ok: false, reason: 'no_phone', message: 'A phone number is required to look up the loyalty wallet.' }) };
    }
    try {
      const { lookupCustomerByPhone, previewRedeem, getCustomerLoyalty } = await import('@server/services/loyalty.service');
      const found = await lookupCustomerByPhone(restaurantId, phone);
      if (!found?.customer_id) {
        return { content: JSON.stringify({ ok: false, reason: 'no_customer', message: 'No loyalty wallet found for that phone — points cannot be redeemed.' }) };
      }
      const view = await getCustomerLoyalty(restaurantId, found.customer_id);
      const preview = await previewRedeem({
        restaurantId,
        customerId: found.customer_id,
        subtotal,
        pointsRequested: requested,
      });
      return {
        content: JSON.stringify({
          ok: true,
          balance_points: view?.wallet?.balance ?? 0,
          requested_points: requested,
          allowed_points: preview.allowed_points,
          discount_amount: preview.discount_amount,
          subtotal_after: Math.max(0, subtotal - preview.discount_amount),
          message: preview.allowed_points > 0
            ? `Redeeming ${preview.allowed_points} points = $${preview.discount_amount.toFixed(2)} off. Confirm with the customer before calling place_order with points_to_redeem=${preview.allowed_points}.`
            : 'No points can be redeemed on this order (balance, minimum, or cap not met).',
        }),
      };
    } catch (err) {
      return { content: JSON.stringify({ ok: false, reason: 'error', message: (err as Error).message }) };
    }
  }

  if (toolName === 'place_order') {
    // Defense-in-depth: ensure loyalty points cannot be redeemed via the
    // public widget even if the model somehow supplies points_to_redeem.
    if (channel === 'widget') {
      toolArgs = { ...toolArgs, points_to_redeem: 0 };
    }
    try {
      const rawItems = Array.isArray(toolArgs.items) ? (toolArgs.items as OrderItem[]) : [];

      // Hard-block: validate every requested item against menu + branch +
      // schedule + stock BEFORE creating the order. Aggregates ALL failures
      // so the model can name every problem in a single reply. Replaces the
      // old behaviour where the model could insert invented items at the
      // price it hallucinated.
      const validation = await validateOrderItemsForAI(
        restaurantId,
        branchId ?? null,
        rawItems.map(it => ({ name: asStr(it.name), quantity: asNum(it.quantity, 1) || 1 }))
      );
      // Menu category scope enforcement: when this agent is restricted to
      // a subset of categories, reject any validated item whose canonical
      // category_id is outside the whitelist (defence in depth — the menu
      // shown to the LLM is also filtered upstream).
      if (validation.ok && menuCategoryIds && menuCategoryIds.length && validation.items) {
        const allowed = new Set(menuCategoryIds);
        const offenders = validation.items.filter(v => {
          const catId = (v as unknown as { category_id?: string | null }).category_id ?? null;
          // When a category scope is active, uncategorized items (catId === null)
          // are also rejected — strict scoping defence.
          return !catId || !allowed.has(catId);
        });
        if (offenders.length) {
          const names = offenders.map(v => v.name).join(', ');
          return {
            content: `I'm sorry — ${names} ${offenders.length === 1 ? 'is' : 'are'} outside the menu categories I'm able to take orders for. Would you like something else from the available menu?`,
          };
        }
      }
      if (!validation.ok) {
        logAIOrderRejections(validation.rejections!, {
          restaurantId,
          branchId: branchId ?? null,
          sessionId: conversationId ?? null,
        });
        const payload = buildRejectionPayload(validation.rejections!);
        // The LLM still gets the prose sentence (it's what the model
        // reasons over and reads back), but we also surface the
        // structured payload so the widget can render per-item cards.
        return { content: payload.message, rejections: payload };
      }

      // Replace caller-supplied items with server-canonical names + prices,
      // preserving any extra per-item fields (notes, modifiers, etc.) that
      // came in from the widget UI. Quantities come from the validator
      // (already coerced to a positive integer).
      const items = rawItems.map((orig, idx) => {
        const v = validation.items![idx];
        return {
          ...orig,
          menu_item_id: v.menu_item_id,
          name: v.name,
          price: v.price,
          quantity: v.quantity,
        };
      });
      const subtotal = items.reduce((sum, item) => sum + asNum(item.price) * (asNum(item.quantity) || 1), 0);
      const tax = parseFloat((subtotal * TAX_RATE).toFixed(2));
      const total = parseFloat((subtotal + tax).toFixed(2));

      // Optional gift card preview — surface a friendly error early if the
      // code is invalid. The actual redemption happens atomically inside
      // createOrder (single source of truth) so we do NOT pre-reduce the
      // total here, otherwise the discount would be applied twice.
      const giftCardCode = asStr(toolArgs.gift_card_code) || null;
      if (giftCardCode) {
        try {
          const { previewRedeem, getSettings } = await import('@server/services/gift-cards.service');
          const settings = await getSettings(restaurantId);
          const offsetable = settings.redemption_rule === 'total' ? total : subtotal;
          await previewRedeem({ restaurantId, code: giftCardCode, amountDue: offsetable });
        } catch (err) {
          const msg = err instanceof Error ? err.message : 'Invalid gift card';
          return { content: `I couldn't apply that gift card: ${msg}` };
        }
      }

      const customerEmail = asStr(toolArgs.customer_email) || null;
      // Channel-aware: orders placed through the WhatsApp pipeline are
      // tagged 'whatsapp' so the inbox / analytics / notification routing
      // can distinguish them from in-widget chat orders.
      const orderChannel = channel === 'whatsapp' ? 'whatsapp'
        : channel === 'voice' ? 'voice'
        : 'chat';
      // Resolve customer_id from phone for loyalty redemption (best-effort)
      const customerPhone = asStr(toolArgs.customer_phone) || customerPhoneFallback || null;
      let resolvedCustomerId: string | null = null;
      const requestedPoints = Math.max(0, Math.floor(asNum(toolArgs.points_to_redeem)));
      if (customerPhone && requestedPoints > 0) {
        try {
          const { lookupCustomerByPhone } = await import('@server/services/loyalty.service');
          const found = await lookupCustomerByPhone(restaurantId, customerPhone);
          resolvedCustomerId = found?.customer_id ?? null;
        } catch { /* loyalty lookup is best-effort */ }
      }

      const order = await createOrder(restaurantId, {
        customer_id: resolvedCustomerId,
        branch_id: branchId ?? null,
        customer_name: asStr(toolArgs.customer_name, channel === 'whatsapp' ? 'WhatsApp Customer' : 'Widget Customer'),
        customer_phone: customerPhone,
        customer_email: customerEmail,
        items,
        delivery_type: asStr(toolArgs.delivery_type, 'dine-in'),
        table_number: asStr(toolArgs.table_number) || null,
        delivery_address: asStr(toolArgs.delivery_address) || null,
        special_instructions: asStr(toolArgs.special_instructions) || null,
        coupon_code: asStr(toolArgs.coupon_code) || null,
        subtotal,
        tax,
        total,
        channel: orderChannel,
        conversation_id: conversationId || null,
        agent_id: agentId ?? null,
        status: 'pending',
        ai_confidence: 100,
        points_to_redeem: requestedPoints,
        gift_card_code: giftCardCode,
      });

      try {
        await createNotification(restaurantId, {
          type: 'order',
          title: 'New order received',
          message: `Order from ${asStr(toolArgs.customer_name, 'Widget Customer')} via widget chat — ${asStr(toolArgs.delivery_type, 'dine-in')}`,
          actionHref: '/order-management',
        });
      } catch { /* Notification failure must never break the order */ }

      const orderRefStr = order?.order_number ? `#${order.order_number}` : String(order?.id ?? '').slice(0, 8).toUpperCase();
      // Pull authoritative totals/coupon from the persisted order so the
      // confirmation always reflects what was actually charged (including
      // any discount applied by `applyCoupon` inside the order transaction).
      const persistedTotal = asNum((order as Record<string, unknown> | null)?.total) || total;
      const persistedSubtotal = asNum((order as Record<string, unknown> | null)?.subtotal) || subtotal;
      const persistedTax = asNum((order as Record<string, unknown> | null)?.tax) || 0;
      const persistedDiscount = asNum((order as Record<string, unknown> | null)?.discount_amount) || 0;
      const persistedCouponCode = asStr((order as Record<string, unknown> | null)?.coupon_code) || null;
      const persistedLoyaltyPoints = asNum((order as Record<string, unknown> | null)?.loyalty_points_redeemed) || 0;
      const persistedLoyaltyDiscount = asNum((order as Record<string, unknown> | null)?.loyalty_discount) || 0;
      const persistedGiftCardApplied = asNum((order as Record<string, unknown> | null)?.gift_card_applied) || 0;
      const giftCardRemainingRaw = (order as Record<string, unknown> | null)?.gift_card_remaining_balance;
      const persistedGiftCardRemaining = giftCardRemainingRaw != null ? asNum(giftCardRemainingRaw) : null;
      if (customerEmail) {
        try {
          const { formatOrderTotalRows } = await import('@server/services/email/format');
          const rows = formatOrderTotalRows({
            subtotal: persistedSubtotal,
            couponCode: persistedCouponCode,
            discountAmount: persistedDiscount,
            loyaltyPointsRedeemed: persistedLoyaltyPoints,
            loyaltyDiscount: persistedLoyaltyDiscount,
            giftCardApplied: persistedGiftCardApplied,
            tax: persistedTax,
          });
          const couponHtml = persistedCouponCode && persistedDiscount > 0
            ? `<div style="margin-top:10px;padding:10px 12px;background:#ecfdf5;border:1px solid #a7f3d0;border-radius:8px;font-size:13px;color:#047857;">Coupon <strong>${persistedCouponCode}</strong> applied — you saved <strong>${formatMoney(persistedDiscount)}</strong></div>`
            : '';
          const giftCardHtml = persistedGiftCardApplied > 0
            ? `<div style="margin-top:10px;padding:10px 12px;background:#eff6ff;border:1px solid #bfdbfe;border-radius:8px;font-size:13px;color:#1d4ed8;">Gift card applied — <strong>−${formatMoney(persistedGiftCardApplied)}</strong>${persistedGiftCardRemaining != null ? ` · remaining balance <strong>${formatMoney(persistedGiftCardRemaining)}</strong>` : ''}</div>`
            : '';
          await enqueueEmail({
            to: customerEmail,
            templateKey: 'order_new_customer',
            channel: 'immediate',
            kind: 'order',
            restaurantId,
            branchId,
            vars: {
              customerName: asStr(toolArgs.customer_name, 'Valued Customer'),
              orderRef: orderRefStr,
              itemsHtml: formatOrderItemsHtml(items.map(i => ({ name: String(i.name), quantity: asNum(i.quantity) || 1, price: asNum(i.price) }))),
              ...rows,
              couponHtml,
              giftCardHtml,
              total: formatMoney(persistedTotal),
              deliveryType: asStr(toolArgs.delivery_type, 'dine-in'),
            },
          });
        } catch { /* never break the order */ }
      }
      try {
        const ownerEmail = await resolveRestaurantOwnerEmail(restaurantId);
        if (ownerEmail) {
          const mode = await getRestaurantNotificationMode(restaurantId);
          await enqueueEmail({
            to: ownerEmail,
            templateKey: 'order_new_restaurant',
            channel: mode,
            kind: 'order',
            restaurantId,
            vars: {
              count: 1,
              ordersHtml: formatOrderSummaryHtml({
                ref: orderRefStr,
                customer: asStr(toolArgs.customer_name, 'Widget Customer'),
                total,
                itemsCount: items.length,
              }),
            },
          });
        }
      } catch { /* never break the order */ }

      const orderNum = order?.order_number ? `#${order.order_number}` : String(order?.id ?? '').slice(0, 8).toUpperCase();
      const emailNote = customerEmail ? ` A confirmation has been sent to ${customerEmail}.` : '';
      const savingsNote = persistedCouponCode && persistedDiscount > 0
        ? ` Coupon ${persistedCouponCode} saved you $${persistedDiscount.toFixed(2)}.`
        : '';

      // WhatsApp customers get a structured confirmation message in
      // addition to the agent's natural-language reply, so the receipt
      // is preserved cleanly in their chat history (and survives any
      // model regression that might omit details from the prose reply).
      if (channel === 'whatsapp' && branchId) {
        // Prefer the AI-supplied customer_phone, but fall back to the
        // sender phone we know from the inbound webhook so confirmations
        // never silently drop just because the model omitted the arg.
        const customerPhone = asStr(toolArgs.customer_phone) || customerPhoneFallback || '';
        if (customerPhone) {
          try {
            const { sendWhatsAppMessage } = await import('@server/services/whatsapp.service');
            const summaryLines = items.map((it) =>
              `• ${asNum(it.quantity) || 1}x ${String(it.name)} — $${(asNum(it.price) * (asNum(it.quantity) || 1)).toFixed(2)}`
            ).join('\n');
            const couponLine = persistedCouponCode && persistedDiscount > 0
              ? `\nCoupon: ${persistedCouponCode} (-$${persistedDiscount.toFixed(2)})`
              : '';
            const confirmation =
              `✅ Order ${orderNum} confirmed\n\n` +
              `${summaryLines}` +
              couponLine + `\n\n` +
              `Total: $${persistedTotal.toFixed(2)} (incl. tax)\n` +
              `Type: ${asStr(toolArgs.delivery_type, 'dine-in')}` +
              (asStr(toolArgs.table_number) ? `\nTable: ${asStr(toolArgs.table_number)}` : '') +
              (asStr(toolArgs.delivery_address) ? `\nAddress: ${asStr(toolArgs.delivery_address)}` : '');
            await sendWhatsAppMessage(branchId, customerPhone, confirmation, {
              conversationId: conversationId || null,
            });
          } catch (err) {
            log.error({ err }, 'Agent: WhatsApp order confirmation failed');
          }
        }
      }

      const giftCardApplied = asNum((order as Record<string, unknown>)?.gift_card_applied);
      const giftCardRemaining = asNum((order as Record<string, unknown>)?.gift_card_remaining_balance);
      let giftCardNote = '';
      if (giftCardCode && giftCardApplied > 0) {
        giftCardNote = ` Gift card ${giftCardCode} applied: −${formatMoney(giftCardApplied)}; remaining balance ${formatMoney(giftCardRemaining)}.`;
      }
      return {
        content: `Order placed successfully! Your order number is **${orderNum}**.${savingsNote}${giftCardNote}${emailNote} The restaurant has been notified.`,
        result: order as OrderResult,
      };
    } catch (err) {
      // ValidationError thrown by createOrder's stock-decrement (race
      // between the validate step above and the INSERT) — surface verbatim
      // so the model can offer alternatives instead of giving a generic
      // "failed" reply.
      if (err instanceof ValidationError) {
        log.warn({ itemId: null, name: '(stock-race)', reason: 'out_of_stock', branchId: branchId ?? null, conversationId: conversationId ?? null, restaurantId }, 'order rejected');
        return { content: `I'm sorry — ${err.message}. Would you like to choose something else from the menu?` };
      }
      const message = err instanceof Error ? err.message : 'Unknown error';
      log.error({ err }, 'Widget Agent place_order failed');
      return { content: `Failed to place order: ${message}. Please try again or contact the restaurant directly.` };
    }
  }

  if (toolName === 'book_table') {
    try {
      // Channel-aware so reservations made over WhatsApp surface in the
      // booking-management UI with the correct channel badge.
      const bookingChannel = channel === 'whatsapp' ? 'whatsapp'
        : channel === 'voice' ? 'voice'
        : 'chat';
      const booking = await createBooking(restaurantId, {
        guest_name: asStr(toolArgs.guest_name),
        guest_phone: asStr(toolArgs.guest_phone) || customerPhoneFallback || null,
        guest_email: asStr(toolArgs.guest_email) || null,
        booking_date: asStr(toolArgs.booking_date),
        booking_time: asStr(toolArgs.booking_time),
        party_size: asNum(toolArgs.party_size) || 1,
        occasion: asStr(toolArgs.occasion) || null,
        special_requests: asStr(toolArgs.special_requests) || null,
        channel: bookingChannel,
        conversation_id: conversationId || null,
        agent_id: agentId ?? null,
        status: 'pending',
      });

      try {
        await createNotification(restaurantId, {
          type: 'booking',
          title: 'New table booking',
          message: `${toolArgs.party_size} guests for ${asStr(toolArgs.guest_name)} on ${asStr(toolArgs.booking_date)} at ${asStr(toolArgs.booking_time)}`,
          actionHref: '/table-booking-management',
        });
      } catch { /* Notification failure must never break the booking */ }

      const bookingRef = String(booking?.id ?? '').slice(0, 8).toUpperCase();
      const guestEmail = asStr(toolArgs.guest_email) || null;
      const bookingDate = asStr(toolArgs.booking_date);
      const bookingTime = asStr(toolArgs.booking_time);
      const partySize = asNum(toolArgs.party_size) || 1;
      const guestName = asStr(toolArgs.guest_name);

      if (guestEmail) {
        try {
          await enqueueEmail({
            to: guestEmail,
            templateKey: 'booking_confirmation_customer',
            channel: 'immediate',
            kind: 'booking',
            restaurantId,
            branchId,
            vars: { guestName, bookingRef, date: bookingDate, time: bookingTime, partySize },
          });

          // Schedule a T-2h reminder if the booking is in the future
          const dt = new Date(`${bookingDate}T${bookingTime}:00`);
          const reminderAt = new Date(dt.getTime() - 2 * 60 * 60 * 1000);
          if (reminderAt.getTime() > Date.now() + 60_000) {
            await enqueueEmail({
              to: guestEmail,
              templateKey: 'booking_reminder_customer',
              channel: 'immediate',
              kind: 'booking',
              restaurantId,
              branchId,
              scheduledFor: reminderAt,
              vars: { guestName, bookingRef, date: bookingDate, time: bookingTime, partySize },
            });
          }
        } catch { /* never break the booking */ }
      }
      try {
        const ownerEmail = await resolveRestaurantOwnerEmail(restaurantId);
        if (ownerEmail) {
          const mode = await getRestaurantNotificationMode(restaurantId);
          await enqueueEmail({
            to: ownerEmail,
            templateKey: 'booking_new_restaurant',
            channel: mode,
            kind: 'booking',
            restaurantId,
            vars: {
              count: 1,
              bookingsHtml: formatBookingSummaryHtml({ ref: bookingRef, guest: guestName, date: bookingDate, time: bookingTime, partySize }),
            },
          });
        }
      } catch { /* never break the booking */ }

      const emailNote = guestEmail ? ` A confirmation has been sent to ${guestEmail}.` : '';

      if (channel === 'whatsapp' && branchId) {
        const guestPhone = asStr(toolArgs.guest_phone) || customerPhoneFallback || '';
        if (guestPhone) {
          try {
            const { sendWhatsAppMessage } = await import('@server/services/whatsapp.service');
            const occasion = asStr(toolArgs.occasion);
            const requests = asStr(toolArgs.special_requests);
            const confirmation =
              `✅ Booking ${bookingRef} confirmed\n\n` +
              `Guest: ${guestName}\n` +
              `Date: ${bookingDate}\n` +
              `Time: ${bookingTime}\n` +
              `Party: ${partySize}` +
              (occasion ? `\nOccasion: ${occasion}` : '') +
              (requests ? `\nNotes: ${requests}` : '');
            await sendWhatsAppMessage(branchId, guestPhone, confirmation, {
              conversationId: conversationId || null,
            });
          } catch (err) {
            log.error({ err }, 'Agent: WhatsApp booking confirmation failed');
          }
        }
      }

      return {
        content: `Booking confirmed! Booking reference: **${bookingRef}**. A table for ${toolArgs.party_size} on ${toolArgs.booking_date} at ${toolArgs.booking_time} has been reserved for ${toolArgs.guest_name}.${emailNote}`,
        result: booking as BookingResult,
      };
    } catch (err) {
      if (err instanceof ConflictError) {
        log.warn({ msg: err.message }, 'Widget Agent book_table conflict');
        return { content: `I'm sorry — ${err.publicMessage}` };
      }
      const message = err instanceof Error ? err.message : 'Unknown error';
      log.error({ err }, 'Widget Agent book_table failed');
      return { content: `Failed to create booking: ${message}. Please try again or contact the restaurant directly.` };
    }
  }

  if (toolName === 'suggest_items') {
    return { content: 'Items displayed to customer as interactive add-to-cart cards.' };
  }

  return { content: 'Tool not found.' };
}

export async function sendAgentMessage(
  messages: ChatMessage[],
  config: AgentConfig,
  restaurantName: string,
  menu: MenuContext,
  restaurantId: string,
  conversationId?: string,
  openaiApiKey?: string,
  branchId?: string,
  channel: AgentChannel = 'widget',
  customerPhoneFallback?: string,
  opts?: SendAgentOptions
): Promise<AgentResponse> {
  const modelMap: Record<string, string> = {
    'gpt-4o': 'gpt-4o',
    'gpt-4o-mini': 'gpt-4o-mini',
    'gpt-5': 'gpt-5',
    'gpt-4.1': 'gpt-4.1',
    'gpt-4.1-mini': 'gpt-4.1-mini',
  };
  const resolvedModel = modelMap[config.model] || DEFAULT_LLM_MODEL;
  const agentId = opts?.agentId ?? null;
  const caps = opts?.capabilities || {};
  const menuCategoryIds = opts?.menuCategoryIds ?? null;
  const knowledgeBaseIds = opts?.knowledgeBaseIds ?? null;

  // Gate the OpenAI tools by per-agent capability flags. Defaults to
  // permissive when caps are absent so legacy callers (no Agent row)
  // keep the prior behaviour.
  // The preview_loyalty_redeem tool is always excluded for the widget
  // channel: the widget is unauthenticated and phone-number lookup is
  // not proof-of-possession — an attacker who knows a victim's phone
  // number could drain their loyalty balance without their consent.
  const enabledTools = ALL_TOOLS.filter(t => {
    const name = (t as { name?: string; function?: { name?: string } }).function?.name
      ?? (t as { name?: string }).name
      ?? '';
    if (name === 'preview_loyalty_redeem' && channel === 'widget') return false;
    if (!Object.keys(caps).length) return true;
    if (name === 'place_order') return caps.order_taking !== false;
    if (name === 'book_table') return caps.reservation_booking !== false;
    if (name === 'search_knowledge_base') return caps.kb_search !== false;
    if (name === 'preview_loyalty_redeem') return caps.loyalty_redemption !== false;
    if (name === 'suggest_items') return caps.menu_recommendations !== false;
    return true;
  });

  // Caller is expected to have already restricted `menu.items` to the
  // allowed categories; `menuCategoryIds` is also threaded into
  // handleToolCall as the authoritative defence on place_order.
  const systemPrompt = buildSystemPrompt(config, restaurantName, menu, channel);

  const apiMessages: object[] = [
    { role: 'system', content: systemPrompt },
    ...messages,
  ];

  const lastUserMsg = [...messages].reverse().find(m => m.role === 'user');
  const detectedIntent = lastUserMsg ? detectIntent(lastUserMsg.content) : 'general';

  if (detectedIntent === 'escalation') {
    return {
      content: "I completely understand. Let me connect you with one of our team members right away. They'll be with you shortly!",
      shouldEscalate: true,
      escalationReason: 'Customer requested human agent',
      detectedIntent,
    };
  }

  let orderCreated: OrderResult | undefined;
  let bookingCreated: BookingResult | undefined;
  let suggestedItems: Array<{ name: string; price: number; description?: string }> | undefined;
  let orderRejections: RejectionPayload | undefined;

  try {
    let currentMessages = [...apiMessages];

    const MAX_ITERATIONS = 5;
    let iterations = 0;

    while (iterations < MAX_ITERATIONS) {
      iterations++;
      const response = await callOpenAIWithTools(currentMessages, resolvedModel, enabledTools, openaiApiKey);
      const choice = response?.choices?.[0];

      if (choice?.finish_reason === 'tool_calls' && choice?.message?.tool_calls?.length) {
        currentMessages = [...currentMessages, choice.message];

        for (const toolCall of choice.message.tool_calls) {
          let args: Record<string, unknown> = {};
          try { args = JSON.parse(toolCall.function?.arguments || '{}') as Record<string, unknown>; } catch { /* ignore */ }

          const toolResult = await handleToolCall(toolCall.function?.name, args, restaurantId, conversationId, branchId, channel, customerPhoneFallback, agentId, menuCategoryIds, knowledgeBaseIds);

          if (toolCall.function?.name === 'place_order' && toolResult.result) {
            orderCreated = toolResult.result;
            // A successful order in this turn supersedes any earlier
            // rejection from a previous tool call in the same loop, so
            // the widget never shows a "couldn't add" card next to a
            // confirmed order receipt.
            orderRejections = undefined;
          }
          if (toolCall.function?.name === 'place_order' && toolResult.rejections && !orderCreated) {
            orderRejections = toolResult.rejections;
          }
          if (toolCall.function?.name === 'book_table' && toolResult.result) {
            bookingCreated = toolResult.result;
          }
          if (toolCall.function?.name === 'suggest_items' && Array.isArray(args.items)) {
            suggestedItems = args.items as Array<{ name: string; price: number; description?: string }>;
          }

          currentMessages.push({
            role: 'tool',
            tool_call_id: toolCall.id,
            content: toolResult.content,
          });
        }

        continue;
      }

      const content: string = choice?.message?.content
        || "I'm sorry, I couldn't process that. Let me connect you with a team member.";

      const escalation = detectEscalation(content);
      const cleanContent = content.replace(/\[ESCALATE:[^\]]*\]/gi, '').trim();

      return {
        content: cleanContent || "I'm sorry, I couldn't process that. Let me connect you with a team member.",
        shouldEscalate: escalation.shouldEscalate || detectedIntent === 'complaint',
        escalationReason: escalation.reason,
        detectedIntent,
        orderCreated,
        bookingCreated,
        suggestedItems,
        orderRejections,
      };
    }

    return {
      content: "I'm sorry, I couldn't complete that request. Let me connect you with a team member.",
      shouldEscalate: false,
      detectedIntent,
      orderCreated,
      bookingCreated,
      suggestedItems,
      orderRejections,
    };
  } catch (err) {
    const errMsg = err instanceof Error ? err.message : String(err);
    log.error({ restaurantId, model: resolvedModel, errMsg }, 'Chat Agent sendAgentMessage failed');
    return {
      content: "I'm having trouble right now. Let me connect you with a team member who can help.",
      shouldEscalate: true,
      escalationReason: 'AI service error',
      detectedIntent,
    };
  }
}
