import { tool } from '@openai/agents';
import { db } from '@server/db/drizzle';
import { sql } from 'drizzle-orm';
import { searchKnowledgeBase, formatSearchResults } from '@server/services/kb-embeddings.service';
import { transferCall } from '@server/services/telephone/twilio-phone.service';
import { enqueueEmail } from '@server/services/email/outbox.service';
import { formatOrderItemsHtml, formatMoney, formatOrderSummaryHtml, formatBookingSummaryHtml, formatOrderTotalRows } from '@server/services/email/format';
import { resolveRestaurantOwnerEmail, getRestaurantNotificationMode } from '@server/services/email/recipients';
import { createBooking } from '@server/services/bookings.service';
import { setCustomerName } from '@server/services/customers.service';
import { recordRedemption, validateCouponForVoiceRedemption } from '@server/services/coupons.service';
import { ConflictError, ValidationError } from '@server/errors';
import { childLogger } from '@server/logger';
import { redactPhone } from '@server/logger/redact';
const log = childLogger('engine.twilio.tools');

import {
  decrementStockForOrder,
  validateOrderItemsForAI,
  buildRejectionMessage,
  logAIOrderRejections,
  type StockDecrementAlert,
  getOrderabilityMap,
} from '@server/services/menu.service';
import { notifyLowStock } from '@server/services/email/notify';
import { TAX_RATE } from '@server/config/ai-defaults';

export interface ToolSessionContext {
  session_id: string;
  restaurant_id: string;
  agent_id: string | null;
  capabilities: Record<string, boolean> | null;
  menu_category_ids?: string[] | null;
  /** Resolved customer row for the caller (Task #323). When present,
   *  enables the update_customer_name tool to persist a name the caller
   *  shares mid-conversation. */
  customer_id?: string | null;
  /** The caller's inbound phone number captured from the SIP/Twilio session.
   *  When present, place_order and book_table pre-fill the phone field so the
   *  agent never has to ask callers for a number they already dialled from. */
  caller_phone?: string | null;
  /** Branch routing mode for multi-branch calls. When 'ask_caller' or
   *  'geo_detect', the set_active_branch tool is added to the session. */
  branch_routing_mode?: 'assigned' | 'ask_caller' | 'geo_detect' | null;
}

export function makeRestaurantTools(ctx: ToolSessionContext) {
  const { session_id, restaurant_id, agent_id, capabilities, menu_category_ids, customer_id, caller_phone, branch_routing_mode } = ctx;
  // Permissive-by-default semantics matching the chat path: when the
  // agent has no capabilities map at all (legacy / freshly created),
  // every tool is available. Only an explicit `false` in the stored
  // map disables a capability.
  const hasCaps = !!capabilities && typeof capabilities === 'object';
  const caps: Record<string, boolean> = capabilities || {};
  const isOn = (key: string) => (hasCaps ? caps[key] !== false : true);

  let resolvedBranchId: string | null | undefined = undefined;

  const resolveBranchId = async (): Promise<string | null> => {
    if (resolvedBranchId !== undefined) return resolvedBranchId;
    try {
      // Prefer the session-level branch_id (set by set_active_branch during
      // multi-branch routing calls) over the static phone_number branch_id.
      /* raw: SELECT COALESCE(s.branch_id, pn.branch_id) AS branch_id FROM sip_call_sessions s JOIN phone_numbers pn ON pn.id = s.phone_number_id WHERE s.id = $1 LIMIT 1 */
      const row = await db.execute(sql`SELECT COALESCE(s.branch_id, pn.branch_id) AS branch_id
         FROM sip_call_sessions s
         LEFT JOIN phone_numbers pn ON pn.id = s.phone_number_id
         WHERE s.id = ${session_id}
         LIMIT 1`);
      resolvedBranchId = (row.rows[0]?.branch_id as string) || null;
    } catch (err) {
      log.error({ err, sessionId: session_id }, 'failed to resolve branch_id for session');
      resolvedBranchId = null;
    }
    return resolvedBranchId;
  };

  const kbSearchTool = tool({
    name: 'search_knowledge_base',
    description:
      'Search the restaurant knowledge base for information about opening hours, policies, menu details, FAQs, and other restaurant-specific information. Use this whenever callers ask about specific restaurant details.',
    parameters: {
      type: 'object' as const,
      properties: {
        query: {
          type: 'string',
          description: 'The search query to find relevant knowledge base entries.',
        },
      },
      required: ['query'] as string[],
      additionalProperties: false as const,
    },
    execute: async (args: unknown) => {
      const { query } = args as { query: string };
      try {
        const results = await searchKnowledgeBase(restaurant_id, query, 5, agent_id);
        return formatSearchResults(results);
      } catch (err) {
        log.error({ err }, 'Twilio KB search error');
        return 'Knowledge base search is temporarily unavailable.';
      }
    },
  });

  const placeOrderTool = tool({
    name: 'place_order',
    description:
      'Save a food order to the system after confirming all details with the caller. Call this tool once you have the full order: items, customer name, phone, and delivery type. The order will be sent to the kitchen staff.',
    parameters: {
      type: 'object' as const,
      properties: {
        items: {
          type: 'array',
          description: 'List of items being ordered.',
          items: {
            type: 'object',
            properties: {
              name: { type: 'string', description: 'Name of the menu item.' },
              quantity: { type: 'number', description: 'Quantity ordered.' },
            },
            required: ['name', 'quantity'],
          },
        },
        customer_name: { type: 'string', description: 'Full name of the customer.' },
        customer_phone: { type: 'string', description: 'Phone number of the customer.' },
        customer_email: { type: 'string', description: 'Email address of the customer for order confirmation (optional).' },
        delivery_type: {
          type: 'string',
          enum: ['dine-in', 'takeaway', 'delivery'],
          description: 'How the customer wants to receive their order.',
        },
        special_instructions: {
          type: 'string',
          description: 'Any special instructions or dietary requirements.',
        },
      },
      required: ['items', 'customer_name', 'delivery_type'] as string[],
      additionalProperties: false as const,
    },
    execute: async (args: unknown) => {
      const raw = args as {
          items: { name: string; quantity: number }[];
          customer_name: string;
          customer_phone?: string;
          customer_email?: string;
          delivery_type: string;
          special_instructions?: string;
        };
      // Pre-fill phone from the inbound call when the agent didn't supply one.
      const { items, customer_name, customer_email, delivery_type, special_instructions } = raw;
      const customer_phone = raw.customer_phone || caller_phone || undefined;
      try {
        const branchId = await resolveBranchId();

        // Hard-block: validate every requested item against menu + branch +
        // schedule + stock BEFORE touching the orders table. Aggregates ALL
        // failures so the model can name every problem in a single reply.
        // Replaces the old silent "price ?? 0" path — items that don't match
        // a menu_items row now hit `unknown_item` and short-circuit instead
        // of being inserted at price 0.
        const validation = await validateOrderItemsForAI(
          restaurant_id,
          branchId ?? null,
          items.map(i => ({ name: i.name, quantity: i.quantity }))
        );
        if (!validation.ok) {
          logAIOrderRejections(validation.rejections!, {
            restaurantId: restaurant_id,
            branchId: branchId ?? null,
            sessionId: session_id,
          });
          return buildRejectionMessage(validation.rejections!);
        }

        // Menu category scope enforcement.
        if (menu_category_ids && menu_category_ids.length) {
          const allowed = new Set(menu_category_ids);
          const offenders = validation.items!.filter(v => {
            const catId = (v as unknown as { category_id?: string | null }).category_id ?? null;
            // Strict: uncategorized items also rejected when a scope is active.
            return !catId || !allowed.has(catId);
          });
          if (offenders.length) {
            const names = offenders.map(v => v.name).join(', ');
            return `Sorry — ${names} ${offenders.length === 1 ? 'is' : 'are'} outside the menu categories I'm able to take orders for. Would you like something else?`;
          }
        }

        const enrichedItems = validation.items!.map(v => ({
          menu_item_id: v.menu_item_id,
          name: v.name,
          quantity: v.quantity,
          price: v.price,
        }));

        const subtotal = enrichedItems.reduce((sum, i) => sum + i.price * i.quantity, 0);
        const tax = parseFloat((subtotal * TAX_RATE).toFixed(2));
        const total = parseFloat((subtotal + tax).toFixed(2));

        // Task #295: low-stock alerts collected by decrementStockForOrder
        // are dispatched AFTER the tx commits — declared in the outer
        // scope so the tx body can populate it.
        let stockAlerts: StockDecrementAlert[] = [];
        const result = await db.transaction(async (tx) => {
          /* raw: SELECT pg_advisory_xact_lock(hashtext($1)::bigint) */
          await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtext(${restaurant_id})::bigint)`);
          /* raw: SELECT COALESCE(MAX(order_number), 0) + 1 AS next_num FROM orders WHERE restaurant_id = $1 */
          const { rows: [numRow] } = await tx.execute(sql`SELECT COALESCE(MAX(order_number), 0) + 1 AS next_num FROM orders WHERE restaurant_id = ${restaurant_id}`);
          const nextOrderNumber = parseInt((numRow as Record<string, unknown>)?.next_num as string ?? '1', 10);
          /* raw: INSERT INTO orders (...) VALUES (...) RETURNING id, order_number */
          const insertResult = await tx.execute(sql`INSERT INTO orders
              (restaurant_id, branch_id, customer_name, customer_phone, customer_email, items, delivery_type,
               special_instructions, channel, status, conversation_id, agent_id,
               subtotal, tax, total, order_number)
             VALUES (${restaurant_id}, ${branchId}, ${customer_name}, ${customer_phone || null}, ${customer_email || null},
               ${JSON.stringify(enrichedItems)}::jsonb, ${delivery_type}, ${special_instructions || null},
               'voice', 'pending', ${session_id}, ${agent_id || null},
               ${subtotal}, ${tax}, ${total}, ${nextOrderNumber})
             RETURNING id, order_number`);

          // Atomic stock reservation. Items already carry their resolved
          // menu_item_id from validation, so the helper takes the FOR UPDATE
          // path directly. Throws ValidationError on sold-out so the entire
          // order rolls back; the outer try/catch surfaces the same
          // "X just sold out" message back to the caller — covers the race
          // window between the orderability check and the INSERT. Task #295:
          // returned alerts are stashed in the outer-scope `stockAlerts` so
          // we can fire owner notifications AFTER the tx commits.
          stockAlerts = await decrementStockForOrder(
            tx,
            restaurant_id,
            branchId ?? null,
            enrichedItems.map(i => ({ menu_item_id: i.menu_item_id, name: i.name, quantity: i.quantity }))
          );

          return insertResult;
        });

        // Post-commit low-stock notifications (rolled-back orders never get
        // this far). Failures inside notifyLowStock are swallowed.
        for (const a of stockAlerts) {
          void notifyLowStock({
            restaurantId: restaurant_id,
            branchId: branchId ?? null,
            itemId: a.itemId,
            itemName: a.itemName,
            stockRemaining: a.stockRemaining,
            threshold: a.threshold,
            kind: a.kind,
          });
        }

        const orderId = result.rows[0]?.id as string;
        const orderNumber = result.rows[0]?.order_number as number | null;
        const itemList = enrichedItems.map(i => `${i.quantity}x ${i.name}`).join(', ');
        const orderRef = orderNumber ? `#${orderNumber}` : orderId.slice(0, 8).toUpperCase();
        log.info({ orderId, itemList, total }, 'order placed');

        if (customer_email) {
          try {
            await enqueueEmail({
              to: customer_email,
              templateKey: 'order_new_customer',
              channel: 'immediate',
              kind: 'order',
              restaurantId: restaurant_id,
              branchId: branchId ?? undefined,
              vars: {
                customerName: customer_name,
                orderRef,
                itemsHtml: formatOrderItemsHtml(enrichedItems),
                couponHtml: '',
                giftCardHtml: '',
                total: formatMoney(total),
                deliveryType: delivery_type,
                // Voice orders don't currently apply coupons / loyalty / gift
                // cards, but the template still expects a full breakdown so
                // the totals table renders without missing rows.
                ...formatOrderTotalRows({
                  subtotal: Number(subtotal) || 0,
                  tax: Number(tax) || 0,
                }),
              },
            });
          } catch { /* never break the order */ }
        }
        try {
          const ownerEmail = await resolveRestaurantOwnerEmail(restaurant_id);
          if (ownerEmail) {
            const mode = await getRestaurantNotificationMode(restaurant_id);
            await enqueueEmail({
              to: ownerEmail,
              templateKey: 'order_new_restaurant',
              channel: mode,
              kind: 'order',
              restaurantId: restaurant_id,
              vars: {
                count: 1,
                ordersHtml: formatOrderSummaryHtml({ ref: orderRef, customer: customer_name, total, itemsCount: enrichedItems.length }),
              },
            });
          }
        } catch { /* never break the order */ }

        return `Order confirmed! Your order (${itemList}) has been placed. Order ${orderRef}. Our team will prepare it shortly.`;
      } catch (err) {
        // ValidationError thrown by decrementStockForOrder carries the
        // sold-out / "only N remaining" reason — surface it verbatim so the
        // model can explain the problem to the caller and offer alternatives
        // (handles the race between validate and insert).
        if (err instanceof ValidationError) {
          log.warn({ itemId: null, name: '(stock-race)', reason: 'out_of_stock', branchId: (await resolveBranchId()) ?? null, session_id, restaurant_id }, 'order rejected');
          return `I'm sorry — ${err.message}. Would you like to choose something else from the menu?`;
        }
        log.error({ err }, 'Twilio place_order error');
        const msg = err instanceof Error ? err.message : '';
        if (/sold out|remaining/i.test(msg)) return msg;
        return 'I was unable to save the order right now. Please ask the caller to call back or visit in person.';
      }
    },
  });

  const bookTableTool = tool({
    name: 'book_table',
    description:
      'Save a table reservation after confirming all details with the caller. Call this tool once you have the guest name, date, time, and party size.',
    parameters: {
      type: 'object' as const,
      properties: {
        guest_name: { type: 'string', description: 'Full name of the guest making the booking.' },
        guest_phone: { type: 'string', description: 'Phone number of the guest.' },
        guest_email: { type: 'string', description: 'Email address of the guest for booking confirmation (optional).' },
        party_size: { type: 'number', description: 'Number of people in the party.' },
        booking_date: {
          type: 'string',
          description: 'Date of the reservation in YYYY-MM-DD format.',
        },
        booking_time: {
          type: 'string',
          description: 'Time of the reservation in HH:MM (24-hour) format.',
        },
        special_requests: {
          type: 'string',
          description: 'Any special requests such as occasion, dietary needs, or seating preference.',
        },
      },
      required: ['guest_name', 'party_size', 'booking_date', 'booking_time'] as string[],
      additionalProperties: false as const,
    },
    execute: async (args: unknown) => {
      const raw2 = args as {
          guest_name: string;
          guest_phone?: string;
          guest_email?: string;
          party_size: number;
          booking_date: string;
          booking_time: string;
          special_requests?: string;
        };
      // Pre-fill phone from the inbound call when the agent didn't supply one.
      const { guest_name, guest_email, party_size, booking_date, booking_time, special_requests } = raw2;
      const guest_phone = raw2.guest_phone || caller_phone || undefined;
      try {
        const branchId = await resolveBranchId();
        // Use the conflict-checked service path so the EXCLUDE constraint
        // and auto-assign helper apply uniformly across UI / chat / voice.
        const booking = await createBooking(restaurant_id, {
          branch_id: branchId,
          guest_name,
          guest_phone: guest_phone || null,
          guest_email: guest_email || null,
          party_size,
          booking_date,
          booking_time,
          special_requests: special_requests || null,
          channel: 'voice',
          status: 'pending',
          conversation_id: session_id,
          agent_id: agent_id || null,
        });
        const bookingId = String((booking as { id?: string })?.id ?? '');
        const bookingRef = bookingId.slice(0, 8).toUpperCase();
        log.info({ bookingId, guest_name }, 'booking created');

        if (guest_email) {
          try {
            await enqueueEmail({
              to: guest_email,
              templateKey: 'booking_confirmation_customer',
              channel: 'immediate',
              kind: 'booking',
              restaurantId: restaurant_id,
              branchId: branchId ?? undefined,
              vars: { guestName: guest_name, bookingRef, date: booking_date, time: booking_time, partySize: party_size },
            });
            const dt = new Date(`${booking_date}T${booking_time}:00`);
            const reminderAt = new Date(dt.getTime() - 2 * 60 * 60 * 1000);
            if (reminderAt.getTime() > Date.now() + 60_000) {
              await enqueueEmail({
                to: guest_email,
                templateKey: 'booking_reminder_customer',
                channel: 'immediate',
                kind: 'booking',
                restaurantId: restaurant_id,
                branchId: branchId ?? undefined,
                scheduledFor: reminderAt,
                vars: { guestName: guest_name, bookingRef, date: booking_date, time: booking_time, partySize: party_size },
              });
            }
          } catch { /* never break the booking */ }
        }
        try {
          const ownerEmail = await resolveRestaurantOwnerEmail(restaurant_id);
          if (ownerEmail) {
            const mode = await getRestaurantNotificationMode(restaurant_id);
            await enqueueEmail({
              to: ownerEmail,
              templateKey: 'booking_new_restaurant',
              channel: mode,
              kind: 'booking',
              restaurantId: restaurant_id,
              vars: {
                count: 1,
                bookingsHtml: formatBookingSummaryHtml({ ref: bookingRef, guest: guest_name, date: booking_date, time: booking_time, partySize: party_size }),
              },
            });
          }
        } catch { /* never break the booking */ }

        return `Booking confirmed! A table for ${party_size} has been reserved for ${guest_name} on ${booking_date} at ${booking_time}. Booking reference: ${bookingRef}.`;
      } catch (err) {
        if (err instanceof ConflictError) {
          log.warn({ msg: err.message }, 'Twilio book_table conflict');
          return `That time slot is already booked. ${err.publicMessage} Please offer the caller a different time.`;
        }
        log.error({ err }, 'Twilio book_table error');
        return 'I was unable to save the reservation right now. Please ask the caller to call back or visit in person.';
      }
    },
  });

  const listMenuTool = tool({
    name: 'list_menu',
    description:
      'List the menu items available for ordering at THIS branch right now. Returns categories and items with availability annotations: items currently sold out, off their schedule, or unavailable at this branch are clearly marked. ALWAYS use this tool — never recite the menu from memory — and skip unavailable items when narrating, or briefly explain why one is unavailable if the caller asks for it by name.',
    parameters: {
      type: 'object' as const,
      properties: {
        category: {
          type: 'string',
          description: 'Optional category name filter (case-insensitive substring) to narrow the listing. Omit to list everything.',
        },
      },
      required: [] as string[],
      additionalProperties: false as const,
    },
    execute: async (args: unknown) => {
      const { category } = (args as { category?: string }) ?? {};
      try {
        const branchId = await resolveBranchId();
        // Pull menu rows visible at this branch — same predicate as the
        // storefront endpoint so the voice agent and the customer see the
        // same picture. Drops mibav-disabled rows and admin-disabled rows;
        // keeps out-of-stock / off-schedule rows so the model can mention
        // them with a reason.
        const categoryFilter = (menu_category_ids && menu_category_ids.length)
          ? sql` AND mi.category_id = ANY(${menu_category_ids}::uuid[])`
          : sql``;
        const { rows } = branchId
          ? await db.execute(sql`
              SELECT mi.id, mi.name, mi.description, mi.price,
                     COALESCE(mc.name, 'Other') AS category_name,
                     COALESCE(mc.display_order, 9999) AS category_order,
                     mi.sort_order
                FROM menu_items mi
                LEFT JOIN menu_categories mc ON mc.id = mi.category_id
                LEFT JOIN menu_item_branch_availability mibav
                  ON mibav.menu_item_id = mi.id AND mibav.branch_id = ${branchId}::uuid
               WHERE mi.restaurant_id = ${restaurant_id}
                 AND (mi.branch_id = ${branchId} OR mi.branch_id IS NULL)
                 AND mi.is_available = true
                 AND COALESCE(mibav.is_available, true) = true
                 AND COALESCE(mc.is_active, true) = true
                 AND (mc.branch_id = ${branchId} OR mc.branch_id IS NULL)
                 ${categoryFilter}
               ORDER BY category_order ASC, mi.sort_order ASC, mi.name ASC
            `)
          : await db.execute(sql`
              SELECT mi.id, mi.name, mi.description, mi.price,
                     COALESCE(mc.name, 'Other') AS category_name,
                     COALESCE(mc.display_order, 9999) AS category_order,
                     mi.sort_order
                FROM menu_items mi
                LEFT JOIN menu_categories mc ON mc.id = mi.category_id
               WHERE mi.restaurant_id = ${restaurant_id}
                 AND mi.is_available = true
                 AND COALESCE(mc.is_active, true) = true
                 ${categoryFilter}
               ORDER BY category_order ASC, mi.sort_order ASC, mi.name ASC
            `);

        type Row = { id: string; name: string; description: string | null; price: string | number; category_name: string };
        const items = rows as Row[];
        if (items.length === 0) {
          return JSON.stringify({ ok: true, categories: [], note: 'No menu items configured for this branch.' });
        }

        const orderMap = await getOrderabilityMap(items.map(r => r.id), branchId ?? null);

        const filterTerm = (category ?? '').trim().toLowerCase();
        type OutItem = {
          name: string;
          price: number;
          orderable: boolean;
          description?: string;
          unavailable_reason?: string;
          unavailable_message?: string;
          available_from?: string;
        };
        const byCat = new Map<string, OutItem[]>();
        for (const r of items) {
          if (filterTerm && !r.category_name.toLowerCase().includes(filterTerm)) continue;
          const ord = orderMap.get(r.id);
          // Hide wrong_branch rows entirely — those should never show up
          // in narration for this branch.
          if (ord && ord.reason === 'wrong_branch') continue;
          const out: OutItem = {
            name: r.name,
            price: typeof r.price === 'number' ? r.price : parseFloat(String(r.price)),
            orderable: ord ? ord.orderable : true,
          };
          // Include a short description so the agent can answer ingredient /
          // content questions without a KB search round-trip (F-7). Cap at
          // 120 chars to keep the token payload reasonable.
          if (r.description) {
            out.description = r.description.length > 120 ? r.description.slice(0, 117) + '…' : r.description;
          }
          if (ord && !ord.orderable) {
            out.unavailable_reason = ord.reason;
            if (ord.message) out.unavailable_message = ord.message;
            if (ord.available_from) out.available_from = ord.available_from;
          }
          const list = byCat.get(r.category_name) ?? [];
          list.push(out);
          byCat.set(r.category_name, list);
        }

        const categories = Array.from(byCat.entries()).map(([name, list]) => ({ name, items: list }));
        return JSON.stringify({
          ok: true,
          categories,
          guidance: 'Narrate only items where orderable=true. If a caller asks about an item that is not orderable, briefly explain using unavailable_message (or unavailable_reason) and suggest an alternative from the orderable list.',
        });
      } catch (err) {
        log.error({ err }, 'Twilio list_menu error');
        return JSON.stringify({ ok: false, message: 'Menu lookup is temporarily unavailable.' });
      }
    },
  });

  const escalateCallTool = tool({
    name: 'escalate_call',
    description:
      'Transfer the call to a human staff member when the caller requests it or when you cannot handle their query. Call this immediately when a caller asks to speak to a person.',
    parameters: {
      type: 'object' as const,
      properties: {
        reason: {
          type: 'string',
          description: 'Brief reason for escalation (e.g. "caller requested human", "complaint handling").',
        },
      },
      required: ['reason'] as string[],
      additionalProperties: false as const,
    },
    execute: async (args: unknown) => {
      const { reason } = args as { reason: string };
      try {
        /* raw: SELECT pn.fallback_number FROM sip_call_sessions s JOIN phone_numbers pn ON pn.id = s.phone_number_id WHERE s.id = $1 AND pn.fallback_number IS NOT NULL LIMIT 1 */
        const fallbackRow = await db.execute(sql`SELECT pn.fallback_number
           FROM sip_call_sessions s
           JOIN phone_numbers pn ON pn.id = s.phone_number_id
           WHERE s.id = ${session_id} AND pn.fallback_number IS NOT NULL
           LIMIT 1`);
        const fallbackNumber = fallbackRow.rows[0]?.fallback_number as string | undefined;

        if (fallbackNumber) {
          log.info({ sessionId: session_id, fallback: redactPhone(fallbackNumber), reason }, 'escalating Twilio call');
          try {
            await transferCall(session_id, restaurant_id, fallbackNumber);
            return 'Please hold for a moment while I connect you to a staff member.';
          } catch (transferErr) {
            log.error({ err: transferErr, sessionId: session_id }, 'Twilio transfer failed');
            return 'I apologise — I was unable to connect the transfer. Please let the caller know a staff member will call them back shortly.';
          }
        } else {
          log.info({ reason }, 'escalation requested but no fallback number configured');
          return 'No transfer number is configured. Please tell the caller: "I\'ll have one of our staff members call you back shortly. Thank you for your patience."';
        }
      } catch (err) {
        log.error({ err }, 'Twilio escalate_call error');
        return 'Please tell the caller a staff member will call them back shortly.';
      }
    },
  });

  // Task #323: persist a name the caller shares mid-conversation. Only
  // exposed when we have a customer row resolved for this call (i.e.
  // the caller had a phone number we could match/auto-create against).
  const updateCustomerNameTool = tool({
    name: 'update_customer_name',
    description:
      "Save the caller's name once they share it. Call this exactly ONCE per call, only after the caller tells you their name. Do not invent a name; if you did not hear it clearly, ask them to repeat it before calling this tool.",
    parameters: {
      type: 'object' as const,
      properties: {
        name: {
          type: 'string',
          description: "The caller's full name as they said it (e.g. \"Priya Sharma\").",
        },
      },
      required: ['name'] as string[],
      additionalProperties: false as const,
    },
    execute: async (args: unknown) => {
      const { name } = args as { name: string };
      const trimmed = (name ?? '').trim();
      if (!trimmed) return 'No name provided; not saved.';
      if (!customer_id) {
        log.info({ sessionId: session_id }, 'update_customer_name called without resolved customer_id');
        return "Noted — I'll remember your name for this call.";
      }
      try {
        const updated = await setCustomerName(restaurant_id, customer_id, trimmed);
        log.info({ sessionId: session_id, updated }, 'update_customer_name');
        return updated
          ? `Saved the caller's name as ${trimmed}.`
          : "Name already on file; not overwritten.";
      } catch (err) {
        log.error({ err, sessionId: session_id }, 'update_customer_name error');
        return "I had trouble saving the name, but I'll remember it for this call.";
      }
    },
  });

  // Task #325: lets the voice agent record that a caller has chosen to use
  // a coupon code mid-call. We do NOT run the full pricing engine here —
  // voice has no real cart and order shape (items / order type) won't be
  // known until the customer actually places the order. Instead we run a
  // lightweight, cart-free eligibility check covering only the things that
  // matter at the moment of acceptance (status, channel, branch, schedule
  // window, total + per-customer caps) and persist a coupon_redemptions
  // row with channel='voice' and discount_amount=0. If the caller later
  // places an order, applyCoupon() re-validates order-shape rules at that
  // point. The dashboard reads these rows for per-channel attribution.
  //
  // In-call dedup: the model is instructed to call this once per offer,
  // but to keep voice conversion stats clean even if it slips up we keep
  // a per-call Set of coupon IDs already recorded and short-circuit on
  // the second call. Closure-scoped — dies with the session, no schema
  // change needed.
  const recordedThisCall = new Set<string>();
  const redeemOfferTool = tool({
    name: 'redeem_offer',
    description:
      "Record that the caller has chosen to redeem a specific coupon code over the phone. Call this ONLY after the caller has explicitly accepted the offer and you have confirmed the code with them. Do not call it just because you mentioned the offer — call it when the caller agrees to use it.",
    parameters: {
      type: 'object' as const,
      properties: {
        code: {
          type: 'string',
          description: 'The coupon code the caller has agreed to use (e.g. "WELCOME10").',
        },
      },
      required: ['code'] as string[],
      additionalProperties: false as const,
    },
    execute: async (args: unknown) => {
      const { code } = args as { code: string };
      const cleaned = (code ?? '').trim();
      if (!cleaned) return 'No coupon code provided; nothing recorded.';
      const branchId = await resolveBranchId();
      if (!branchId) {
        // branch_id is NOT NULL in the schema; silently skip rather than
        // surfacing a technical message the AI would read aloud to the caller.
        log.info({ sessionId: session_id, code: cleaned }, 'redeem_offer: no branch resolved — skipping silently');
        return '[system: redemption skipped — branch unknown]';
      }
      try {
        const result = await validateCouponForVoiceRedemption({
          restaurantId: restaurant_id,
          branchId,
          customerId: customer_id ?? null,
          code: cleaned,
        });
        if (!result.ok) {
          log.info({ sessionId: session_id, code: cleaned, reason: result.reason }, 'redeem_offer rejected');
          return result.message;
        }
        if (recordedThisCall.has(result.coupon.id)) {
          log.info({ sessionId: session_id, couponId: result.coupon.id }, 'redeem_offer skipped (already recorded this call)');
          return `You're all set with ${result.coupon.code} — I've already noted it for this call.`;
        }
        await recordRedemption({
          couponId: result.coupon.id,
          orderId: null,
          restaurantId: restaurant_id,
          branchId,
          customerId: customer_id ?? null,
          channel: 'voice',
          discountAmount: 0,
        });
        recordedThisCall.add(result.coupon.id);
        log.info({ sessionId: session_id, couponId: result.coupon.id, code: result.coupon.code }, 'redeem_offer recorded');
        return `Got it — I've noted that you'd like to use ${result.coupon.code}. Please show this code at the restaurant to redeem.`;
      } catch (err) {
        log.error({ err, sessionId: session_id, code: cleaned }, 'redeem_offer error');
        return "I had trouble recording that just now, but please mention the code when you arrive and our staff will honour it.";
      }
    },
  });

  // ─── set_active_branch (Task #529) ───────────────────────────────────────
  // Available on multi-branch routing modes. Updates sip_call_sessions.branch_id
  // for the duration of the call and returns the chosen branch context so the
  // agent can contextualise the selection for the caller.
  const setActiveBranchTool = tool({
    name: 'set_active_branch',
    description:
      'Set the active branch for this call after the caller has chosen or confirmed it. Call this exactly once per call, as soon as the caller confirms which branch they want. Do NOT call it before the caller confirms.',
    parameters: {
      type: 'object' as const,
      properties: {
        branch_id: {
          type: 'string',
          description: 'The UUID of the branch the caller chose.',
        },
        confirmation_message: {
          type: 'string',
          description: 'The short phrase you spoke to confirm the branch choice (e.g. "Great, connecting you to our Main Street location").',
        },
      },
      required: ['branch_id', 'confirmation_message'] as string[],
      additionalProperties: false as const,
    },
    execute: async (args: unknown) => {
      const { branch_id } = args as { branch_id: string; confirmation_message: string };
      try {
        /* raw: SELECT id, name, address, phone, hours FROM branches WHERE id = $1 AND restaurant_id = $2 LIMIT 1 */
        const { rows: branchRows } = await db.execute(sql`
          SELECT id, name, address, phone, hours
            FROM branches
           WHERE id = ${branch_id}
             AND restaurant_id = ${restaurant_id}
           LIMIT 1
        `);
        if (!branchRows[0]) {
          log.warn({ branchId: branch_id, sessionId: session_id }, 'set_active_branch: branch not found');
          return 'Branch not found — please ask the caller to choose again.';
        }
        /* raw: UPDATE sip_call_sessions SET branch_id = $1 WHERE id = $2 */
        await db.execute(sql`
          UPDATE sip_call_sessions
             SET branch_id = ${branch_id}
           WHERE id = ${session_id}
        `);
        // Also update the cached resolveBranchId result so subsequent tool
        // calls (place_order, book_table, list_menu) use the new branch.
        resolvedBranchId = branch_id;

        // Persist the chosen branch on the customer record so repeat callers
        // skip the routing question next time. Failures are swallowed — a
        // missed preference write must NEVER interrupt a live call.
        if (customer_id) {
          try {
            /* raw: UPDATE customers SET preferred_branch_id = $1 WHERE id = $2 AND restaurant_id = $3 */
            await db.execute(sql`
              UPDATE customers
                 SET preferred_branch_id = ${branch_id},
                     updated_at = NOW()
               WHERE id = ${customer_id}
                 AND restaurant_id = ${restaurant_id}
            `);
          } catch (prefErr) {
            log.warn({ err: prefErr, sessionId: session_id, customerId: customer_id }, 'set_active_branch: failed to persist preferred_branch_id');
          }
        }

        const b = branchRows[0] as {
          id: string; name: string; address: string | null;
          phone: string | null; hours: Record<string, { open: string; close: string; closed?: boolean }> | null;
        };
        const parts: string[] = [`Branch confirmed: ${b.name}`];
        if (b.address) parts.push(`Address: ${b.address}`);
        if (b.phone) parts.push(`Phone: ${b.phone}`);
        log.info({ sessionId: session_id, branchId: branch_id, branchName: b.name }, 'set_active_branch');
        return parts.join(' | ') + '. You may now proceed with taking orders or bookings for this branch.';
      } catch (err) {
        log.error({ err, sessionId: session_id, branchId: branch_id }, 'set_active_branch error');
        return 'I had trouble confirming the branch. Please ask the caller to hold briefly and try again.';
      }
    },
  });

  type RestaurantTool =
    | typeof kbSearchTool
    | typeof listMenuTool
    | typeof placeOrderTool
    | typeof bookTableTool
    | typeof escalateCallTool
    | typeof updateCustomerNameTool
    | typeof redeemOfferTool
    | typeof setActiveBranchTool;

  // list_menu is always available — even agents with no order-taking
  // capability still answer "what's on the menu?" questions, and reading
  // the live availability map prevents the model from inventing items
  // or recommending sold-out / off-schedule dishes from memory.
  // KB search is gated by the kb_search capability (defaults to ON when
  // capabilities are not explicitly set, to preserve legacy behaviour).
  const tools: RestaurantTool[] = [];
  if (isOn('kb_search')) tools.push(kbSearchTool);
  tools.push(listMenuTool);

  if (isOn('order_taking')) tools.push(placeOrderTool);
  if (isOn('reservation_booking')) tools.push(bookTableTool);
  if (isOn('escalation')) tools.push(escalateCallTool);
  if (customer_id) tools.push(updateCustomerNameTool);
  // redeem_offer is always available — it is harmless when no offers
  // apply (returns a friendly "couldn't find that code" message) and
  // capability gating it would require yet another flag for operators.
  tools.push(redeemOfferTool);
  // set_active_branch: only for multi-branch routing modes.
  if (branch_routing_mode === 'ask_caller' || branch_routing_mode === 'geo_detect') {
    tools.push(setActiveBranchTool);
  }

  return tools;
}
