/**
 * WhatsApp Cloud API webhook endpoint — one per branch.
 *
 * GET  /api/channels/whatsapp/webhook/[branchId]  Meta verification handshake.
 * POST /api/channels/whatsapp/webhook/[branchId]  Inbound message delivery.
 *
 * Public (no session). All security comes from:
 *   1. Path-bound branchId → looks up the encrypted credential row
 *   2. GET handshake matches hub.verify_token from saved row
 *   3. POST X-Hub-Signature-256 HMAC verified against the saved app_secret
 *      (when present — operators may skip during initial integration)
 *   4. Idempotent insert keyed on (branch_id, wa_message_id)
 */

import { NextRequest, NextResponse } from 'next/server';
import { after } from 'next/server';
import { createHmac, timingSafeEqual } from 'crypto';
import { db } from '@server/db/drizzle';
import { sql } from 'drizzle-orm';
import { initDatabase } from '@server/db/init';
import { DEFAULT_LLM_MODEL } from '@server/config/ai-defaults';
import { childLogger } from '@server/logger';
import { redactPhone } from '@server/logger/redact';
import { wrapRouteHandler } from '@server/logger/request';
const log = childLogger('webhook.whatsapp');

import {
  getBranchCredsByBranchId,
  logInboundIfNew,
  attachConversationToInbound,
  sendWhatsAppMessage,
  sanitizeForWhatsApp,
  applyStatusUpdates,
} from '@server/services/whatsapp.service';
import { allowInbound } from '@server/services/whatsapp/rate-limit';
import { sendAgentMessage } from '@server/integrations/ai/restaurantChatAgent';
import { filterOrderableItems } from '@server/services/menu.service';
import { resolveApiKey } from '@server/services/provider-keys.service';
import { broadcastEscalation } from '@server/utils/escalationBus';
import { findOrCreateCustomerByPhone as sharedFindOrCreateCustomerByPhone } from '@server/services/customers.service';

interface RouteContext { params: Promise<{ branchId: string }> }

// ---------- GET: verification handshake ----------

async function handleWhatsAppGet(req: NextRequest, context: RouteContext): Promise<NextResponse> {
  const { branchId } = await context.params;
  const url = new URL(req.url);
  const mode = url.searchParams.get('hub.mode');
  const token = url.searchParams.get('hub.verify_token');
  const challenge = url.searchParams.get('hub.challenge');

  if (mode !== 'subscribe' || !token || !challenge) {
    return new NextResponse('Bad Request', { status: 400 });
  }

  const creds = await getBranchCredsByBranchId(branchId);
  if (!creds) return new NextResponse('Forbidden', { status: 403 });
  if (creds.verifyToken !== token) return new NextResponse('Forbidden', { status: 403 });

  return new NextResponse(challenge, { status: 200, headers: { 'Content-Type': 'text/plain' } });
}

// ---------- POST: inbound delivery ----------

interface MetaMessage {
  id: string;
  from: string;
  timestamp: string;
  type: string;
  text?: { body: string };
  button?: { text?: string };
  interactive?: { button_reply?: { title?: string }; list_reply?: { title?: string } };
  image?: { id: string; caption?: string; mime_type?: string };
  audio?: { id: string; mime_type?: string };
  document?: { id: string; filename?: string; caption?: string };
  location?: { latitude: number; longitude: number; name?: string; address?: string };
}

interface MetaContact { wa_id: string; profile?: { name?: string } }

interface MetaChange {
  field: string;
  value: {
    messaging_product?: string;
    metadata?: { phone_number_id?: string; display_phone_number?: string };
    contacts?: MetaContact[];
    messages?: MetaMessage[];
    statuses?: Array<{ id: string; status: string; recipient_id?: string }>;
  };
}

interface MetaWebhookPayload {
  object?: string;
  entry?: Array<{ id?: string; changes?: MetaChange[] }>;
}

function verifyHmac(rawBody: string, headerValue: string | null, appSecret: string): boolean {
  if (!headerValue) return false;
  // Header looks like "sha256=<hex>"
  const sigHex = headerValue.startsWith('sha256=') ? headerValue.slice(7) : headerValue;
  if (!/^[0-9a-fA-F]+$/.test(sigHex)) return false;
  const expected = createHmac('sha256', appSecret).update(rawBody, 'utf8').digest();
  let provided: Buffer;
  try { provided = Buffer.from(sigHex, 'hex'); } catch { return false; }
  if (provided.length !== expected.length) return false;
  return timingSafeEqual(expected, provided);
}

function extractText(msg: MetaMessage): string | null {
  if (msg.text?.body) return msg.text.body;
  if (msg.button?.text) return msg.button.text;
  if (msg.interactive?.button_reply?.title) return msg.interactive.button_reply.title;
  if (msg.interactive?.list_reply?.title) return msg.interactive.list_reply.title;
  if (msg.image?.caption) return msg.image.caption;
  if (msg.document?.caption) return `[Document: ${msg.document.filename ?? 'attachment'}] ${msg.document.caption ?? ''}`.trim();
  if (msg.location) {
    const place = msg.location.name ? `${msg.location.name} (${msg.location.address ?? ''})` : `${msg.location.latitude}, ${msg.location.longitude}`;
    return `[Location: ${place}]`;
  }
  if (msg.audio) return '[Audio message — not yet transcribed]';
  return null;
}

function toE164(wa: string): string {
  // Meta sends bare digits (no +) — normalize for storage.
  return wa.startsWith('+') ? wa : `+${wa}`;
}

interface CustomerRow { id: string; name: string | null }

/**
 * Thin adapter over the shared `findOrCreateCustomerByPhone` helper in
 * customers.service so WhatsApp + voice share one implementation
 * (Task #323). Preserves the legacy "WhatsApp 1234" placeholder name
 * by passing the profile name (if any) through to the shared helper —
 * the helper falls back to "Caller 1234" when no profile name is
 * supplied, so we substitute the WhatsApp-flavoured placeholder here
 * to keep historical labels consistent.
 */
async function findOrCreateCustomerByPhone(
  restaurantId: string,
  phoneE164: string,
  profileName?: string
): Promise<CustomerRow> {
  const profile = profileName?.trim() || `WhatsApp ${phoneE164.slice(-4)}`;
  const result = await sharedFindOrCreateCustomerByPhone(restaurantId, phoneE164, {
    sourceLabel: 'WhatsApp inbound',
    profileName: profile,
  });
  if (result) return { id: result.customer.id, name: result.customer.name };
  // Defensive fallback: shared helper only returns null on bad/empty phone,
  // which we've already normalized via toE164(). Synthesize a placeholder
  // row id so the caller-side flow can short-circuit gracefully.
  throw new Error(`findOrCreateCustomerByPhone returned null for ${phoneE164}`);
}

interface ConversationLite { id: string; status: string; messages: unknown }

async function findOrCreateConversation(
  restaurantId: string,
  branchId: string,
  customerId: string,
  customerName: string,
  customerPhone: string
): Promise<ConversationLite> {
  // Try to reuse an open WhatsApp conversation for this customer in the last
  // 12 hours (mirrors WhatsApp's session window) so we keep history continuity.
  // Scoped per branch so the same customer messaging two different branches
  // is treated as two separate conversations (matches branch-level routing).
  const { rows: existing } = await db.execute(sql`
    SELECT id, status, messages FROM conversations
    WHERE restaurant_id = ${restaurantId}
      AND branch_id = ${branchId}
      AND customer_id = ${customerId}
      AND channel = 'whatsapp'
      AND status IN ('ai','human')
      AND updated_at > NOW() - INTERVAL '12 hours'
    ORDER BY updated_at DESC LIMIT 1
  `);
  const found = existing[0] as unknown as ConversationLite | undefined;
  if (found) return found;

  const { rows: ins } = await db.execute(sql`
    INSERT INTO conversations (restaurant_id, branch_id, customer_id, customer_name,
                               channel, status, messages, topic, unread_count)
    VALUES (${restaurantId}, ${branchId}, ${customerId}, ${customerName},
            'whatsapp', 'ai', '[]'::jsonb, ${'WhatsApp · ' + customerPhone}, 0)
    RETURNING id, status, messages
  `);
  return ins[0] as unknown as ConversationLite;
}

async function appendConvMessage(
  conversationId: string,
  restaurantId: string,
  sender: 'customer' | 'agent' | 'system',
  content: string
): Promise<void> {
  const msg = {
    id: `wa_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
    sender,
    role: sender === 'customer' ? 'user' : sender === 'agent' ? 'assistant' : 'system',
    content,
    text: content,
    time: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
    timestamp: new Date().toISOString(),
  };
  await db.execute(sql`
    UPDATE conversations
    SET messages = messages || ${JSON.stringify([msg])}::jsonb,
        unread_count = CASE WHEN ${sender} = 'customer' THEN unread_count + 1 ELSE unread_count END,
        updated_at = NOW()
    WHERE id = ${conversationId} AND restaurant_id = ${restaurantId}
  `);
}

interface AgentRow {
  agent_id: string;
  system_prompt: string | null;
  greeting_script: string | null;
  closing_script: string | null;
  fallback_rules: Array<{ trigger: string; action: string; priority: string }> | null;
  model_id: string | null;
  capabilities: Record<string, boolean> | null;
  menu_category_ids: string[] | null;
}

async function loadAgentConfig(restaurantId: string): Promise<{
  model: string; systemPrompt?: string; responseStyle?: string;
  greetingScript?: string; closingScript?: string;
  fallbackRules?: Array<{ trigger: string; action: string; priority: string }>;
  agentId?: string | null;
  capabilities?: Record<string, boolean> | null;
  menuCategoryIds?: string[] | null;
} | null> {
  const [agentResult, legacyResult] = await Promise.all([
    db.execute(sql`
      SELECT a.id AS agent_id, a.system_prompt, a.greeting_script, a.closing_script,
             a.fallback_rules, a.capabilities, a.menu_category_ids, lm.model_id
      FROM ai_agents a LEFT JOIN llm_models lm ON lm.id = a.llm_model_id
      WHERE a.restaurant_id = ${restaurantId} AND a.is_active = true
        AND ('chat' = ANY(a.channels) OR 'whatsapp' = ANY(a.channels))
      ORDER BY a.is_default DESC, a.created_at LIMIT 1
    `),
    db.execute(sql`SELECT * FROM ai_agent_configs WHERE restaurant_id = ${restaurantId} LIMIT 1`),
  ]);
  const agent = agentResult.rows[0] as unknown as AgentRow | undefined;
  const legacy = legacyResult.rows[0] as Record<string, unknown> | undefined;
  if (!agent && !legacy) return null;
  return {
    model: agent?.model_id || (legacy?.model as string) || DEFAULT_LLM_MODEL,
    systemPrompt: agent?.system_prompt ?? (legacy?.system_prompt as string | undefined),
    responseStyle: (legacy?.response_style as string) || 'friendly',
    greetingScript: agent?.greeting_script ?? (legacy?.greeting_script as string | undefined),
    closingScript: agent?.closing_script ?? (legacy?.closing_script as string | undefined),
    fallbackRules: Array.isArray(agent?.fallback_rules)
      ? agent!.fallback_rules!
      : (Array.isArray(legacy?.fallback_rules) ? legacy.fallback_rules as Array<{ trigger: string; action: string; priority: string }> : []),
    agentId: agent?.agent_id ?? null,
    capabilities: agent?.capabilities ?? null,
    menuCategoryIds: Array.isArray(agent?.menu_category_ids) ? agent!.menu_category_ids : null,
  };
}

interface MessageHistoryItem { role: 'user' | 'assistant'; content: string }

function buildHistory(rawMessages: unknown): MessageHistoryItem[] {
  if (!Array.isArray(rawMessages)) return [];
  const out: MessageHistoryItem[] = [];
  for (const m of rawMessages.slice(-12)) {
    const obj = m as Record<string, unknown>;
    const role = obj?.role === 'assistant' ? 'assistant' : obj?.role === 'user' ? 'user'
      : obj?.sender === 'agent' ? 'assistant' : obj?.sender === 'customer' ? 'user' : null;
    const content = typeof obj?.content === 'string' ? obj.content
      : typeof obj?.text === 'string' ? obj.text : '';
    if (role && content) out.push({ role, content });
  }
  return out;
}

async function processMessage(
  branchId: string,
  message: MetaMessage,
  contact: MetaContact | undefined
): Promise<void> {
  const creds = await getBranchCredsByBranchId(branchId);
  if (!creds) return; // already verified above, but defensive

  // Short-circuit when the operator has flipped the branch off. We log
  // nothing and don't fan out to the AI agent — the channel is paused.
  // (Distinct from "no credentials": creds exist, just temporarily off.)
  if (!creds.isActive) {
    log.warn({ branchId }, 'inactive branch — skipping inbound delivery');
    return;
  }

  const fromE164 = toE164(message.from);
  const text = extractText(message);
  const messageType = message.type || 'unknown';

  // Per-(branch, sender) inbound rate limit to keep a misbehaving customer
  // — or a spoofed flood during the unsigned-grace window — from draining
  // OpenAI credits or polluting the conversation log. Drops silently
  // (Meta will not retry; we already 200'd at the route handler).
  if (!allowInbound(branchId, fromE164)) {
    log.warn({ branchId, from: redactPhone(fromE164) }, 'WhatsApp webhook rate-limit drop');
    return;
  }

  // Idempotency check first — skip everything if Meta replayed this id.
  const isNew = await logInboundIfNew({
    branchId,
    restaurantId: creds.restaurantId,
    conversationId: null,
    waMessageId: message.id,
    fromE164,
    body: text,
    messageType,
  });
  if (!isNew) return;

  const customer = await findOrCreateCustomerByPhone(
    creds.restaurantId,
    fromE164,
    contact?.profile?.name
  );

  const conv = await findOrCreateConversation(
    creds.restaurantId,
    branchId,
    customer.id,
    customer.name || contact?.profile?.name || `WhatsApp ${fromE164.slice(-4)}`,
    fromE164
  );

  await attachConversationToInbound(message.id, branchId, conv.id);

  if (!text) {
    // Unsupported media — let the user know we can't process it.
    await appendConvMessage(conv.id, creds.restaurantId, 'customer', `[${messageType} message received]`);
    await sendWhatsAppMessage(branchId, fromE164,
      "I received your message but I can only respond to text right now. Could you please type your request?",
      { conversationId: conv.id }
    );
    return;
  }

  await appendConvMessage(conv.id, creds.restaurantId, 'customer', text);

  // ─── Marketing STOP intercept (Task #215) ────────────────────────────────
  // Honour Meta's expected opt-out keywords. Flip marketing_opt_out on the
  // customer row, send a confirmation, and SKIP the AI handoff so the
  // request never burns a model call. We deliberately use the existing
  // sendWhatsAppMessage path (24-hour session window is open because the
  // customer just sent us a message).
  if (/^\s*(STOP|UNSUBSCRIBE|END|QUIT|CANCEL)\s*$/i.test(text)) {
    try {
      await db.execute(sql`
        UPDATE customers
        SET marketing_opt_out = true,
            opt_out_at = COALESCE(opt_out_at, now()),
            opt_out_reason = COALESCE(opt_out_reason, 'whatsapp STOP')
        WHERE id = ${customer.id} AND restaurant_id = ${creds.restaurantId}
      `);
    } catch (err) {
      log.error({ err, branchId }, 'whatsapp STOP opt-out update failed');
    }
    await sendWhatsAppMessage(branchId, fromE164,
      "You've been unsubscribed from marketing messages. You will still receive replies about your orders and bookings. Reply START to opt back in.",
      { conversationId: conv.id }
    );
    await appendConvMessage(conv.id, creds.restaurantId, 'system', 'Customer opted out of marketing (STOP).');
    return;
  }
  // START re-opt-in (mirror keyword Meta encourages).
  if (/^\s*(START|SUBSCRIBE|UNSTOP)\s*$/i.test(text)) {
    try {
      await db.execute(sql`
        UPDATE customers
        SET marketing_opt_out = false, opt_out_at = NULL, opt_out_reason = NULL
        WHERE id = ${customer.id} AND restaurant_id = ${creds.restaurantId}
      `);
    } catch (err) {
      log.error({ err, branchId }, 'whatsapp START opt-in update failed');
    }
    await sendWhatsAppMessage(branchId, fromE164,
      "Welcome back! You've been re-subscribed to marketing messages.",
      { conversationId: conv.id }
    );
    await appendConvMessage(conv.id, creds.restaurantId, 'system', 'Customer opted back into marketing (START).');
    return;
  }

  // If the conversation is already in human-handoff mode, do NOT auto-reply.
  if (conv.status === 'human') return;

  const agentConfig = await loadAgentConfig(creds.restaurantId);
  if (!agentConfig) {
    await sendWhatsAppMessage(branchId, fromE164,
      "Thanks for your message — our team will get back to you shortly.",
      { conversationId: conv.id }
    );
    return;
  }

  const [restaurantRow] = (await db.execute(sql`SELECT name FROM restaurants WHERE id = ${creds.restaurantId} LIMIT 1`)).rows;
  const restaurantName = (restaurantRow as { name?: string } | undefined)?.name || 'Our Restaurant';
  const { rows: menuRows } = await db.execute(sql`
    SELECT mi.id, mi.name, mi.price, mi.description, mi.category_id, mc.name AS category
    FROM menu_items mi
    LEFT JOIN menu_categories mc ON mc.id = mi.category_id
    WHERE mi.restaurant_id = ${creds.restaurantId} AND mi.is_available = true LIMIT 60
  `);
  // Filter the LLM-visible menu so disabled / out-of-stock / off-schedule /
  // wrong-branch items disappear automatically, and restrict to the agent's
  // allowed menu_category_ids whitelist (if any). place_order re-validates
  // server-side regardless.
  const allowedCats = agentConfig.menuCategoryIds && agentConfig.menuCategoryIds.length
    ? new Set(agentConfig.menuCategoryIds) : null;
  const allItems = (menuRows as Array<{ id: string; name: string; price: number; description: string; category_id: string | null; category: string | null }>)
    .filter(r => !allowedCats || (r.category_id && allowedCats.has(r.category_id)))
    .map(r => ({
      id: r.id,
      name: r.name,
      price: r.price,
      description: r.description,
      category: r.category ?? undefined,
    }));
  const menu = {
    items: await filterOrderableItems(allItems, branchId),
  };

  const history: MessageHistoryItem[] = [
    ...buildHistory(conv.messages),
    { role: 'user', content: text },
  ];

  const apiKey = await resolveApiKey(creds.restaurantId, 'openai');
  const agentResponse = await sendAgentMessage(
    history,
    agentConfig,
    restaurantName,
    menu,
    creds.restaurantId,
    conv.id,
    apiKey ?? undefined,
    branchId,
    'whatsapp',
    fromE164,  // confirmation fallback when the model omits customer_phone
    {
      agentId: agentConfig.agentId ?? null,
      capabilities: agentConfig.capabilities ?? null,
      menuCategoryIds: agentConfig.menuCategoryIds ?? null,
    }
  );

  const reply = sanitizeForWhatsApp(agentResponse.content) || "I'm here to help. Could you tell me what you'd like to order or ask?";
  await appendConvMessage(conv.id, creds.restaurantId, 'agent', reply);
  await sendWhatsAppMessage(branchId, fromE164, reply, { conversationId: conv.id });

  if (agentResponse.shouldEscalate) {
    try {
      await db.execute(sql`UPDATE conversations SET status = 'human', updated_at = NOW() WHERE id = ${conv.id} AND restaurant_id = ${creds.restaurantId}`);
      broadcastEscalation({
        restaurantId: creds.restaurantId,
        conversationId: conv.id,
        reason: agentResponse.escalationReason || 'Escalation requested',
        customerName: customer.name || fromE164,
      });
    } catch (err) {
      log.error({ err, branchId }, 'whatsapp escalation update failed');
    }
  }
}

async function handleWhatsAppPost(req: NextRequest, context: RouteContext): Promise<NextResponse> {
  const { branchId } = await context.params;
  const rawBody = await req.text();

  let creds;
  try {
    await initDatabase();
    creds = await getBranchCredsByBranchId(branchId);
  } catch (err) {
    log.error({ err, branchId }, 'whatsapp credential lookup failed');
    return new NextResponse('OK', { status: 200 });
  }
  if (!creds) {
    // Always 200 for unknown branches — never give Meta a reason to disable
    // delivery if an operator deletes a credential while inflight events
    // are queued.
    return new NextResponse('OK', { status: 200 });
  }

  // Secure-by-default signature verification:
  //   - When an app_secret is configured: HMAC must match, period.
  //   - When no app_secret is configured AND we're in production: drop
  //     immediately. Production deployments must pin the app secret —
  //     no grace window in prod.
  //   - When no app_secret is configured in dev/staging: unsigned POSTs
  //     are tolerated only during a 1-hour grace window from first
  //     credential save (so operators can complete the initial Meta
  //     integration). After expiry, drop with 200 to keep Meta from
  //     retrying.
  const sigHeader = req.headers.get('x-hub-signature-256');
  let signatureValid: boolean | null = null;
  if (creds.appSecret) {
    signatureValid = verifyHmac(rawBody, sigHeader, creds.appSecret);
    if (!signatureValid) {
      log.info({ signatureValid: false, event: 'whatsapp.message', id: branchId, type: 'webhook.received' }, 'webhook.received');
      log.warn({ branchId }, 'signature mismatch');
      return new NextResponse('Forbidden', { status: 403 });
    }
  }
  log.info(
    { signatureValid, event: 'whatsapp.message', id: branchId, type: 'webhook.received' },
    'webhook.received'
  );
  if (!creds.appSecret) {
    if (process.env.NODE_ENV === 'production') {
      log.error(
        `[WhatsApp webhook] PRODUCTION-DROP unsigned POST for branch ${branchId}: ` +
        `app_secret is required outside development`
      );
      return new NextResponse('OK', { status: 200 });
    }
    const graceUntil = creds.unsignedGraceUntil;
    if (!graceUntil || graceUntil.getTime() <= Date.now()) {
      log.warn(
        `[WhatsApp webhook] rejecting unsigned POST for branch ${branchId} ` +
        `(no app_secret + 1-hour grace window expired); ` +
        `operator must configure app_secret in the WhatsApp integration settings`
      );
      return new NextResponse('Forbidden', { status: 403 });
    }
  }

  let payload: MetaWebhookPayload;
  try { payload = JSON.parse(rawBody) as MetaWebhookPayload; }
  catch { return new NextResponse('OK', { status: 200 }); }

  // Fast-ack pattern: schedule the heavy work to run AFTER the 200 has
  // been sent back to Meta. Meta retries on slow/non-2xx; long AI
  // round-trips inside the request would block the ack and trigger
  // duplicate retries. `after()` (Next.js 15) defers the callback until
  // after the response flushes, while still keeping the function
  // process alive long enough to finish.
  const phoneNumberId = creds.phoneNumberId;
  after(async () => {
    try {
      for (const entry of payload.entry || []) {
        for (const change of entry.changes || []) {
          // Defense-in-depth: if Meta tells us this delivery was for a
          // different phone_number_id than the one we have stored for
          // this branch, drop the entry entirely. Prevents one branch's
          // webhook from accidentally handling another branch's
          // customer messages.
          const eventPnid = change.value?.metadata?.phone_number_id;
          if (eventPnid && eventPnid !== phoneNumberId) {
            // Don't log raw phone_number_ids — Meta treats these as PII.
            // The mismatch fact + branch is sufficient for triage.
            log.warn(
              `[WhatsApp webhook] phone_number_id mismatch for branch ${branchId} — skipping entry`
            );
            continue;
          }
          const messages = change.value?.messages || [];
          const contacts = change.value?.contacts || [];
          const contactMap = new Map(contacts.map((c) => [c.wa_id, c]));
          for (const message of messages) {
            try {
              await processMessage(branchId, message, contactMap.get(message.from));
            } catch (err) {
              log.error({ err, branchId, messageId: message.id }, 'whatsapp processMessage failed');
            }
          }
          const statuses = change.value?.statuses || [];
          if (statuses.length) {
            try {
              await applyStatusUpdates(branchId, statuses);
            } catch (err) {
              log.error({ err, branchId }, 'whatsapp status update failed');
            }
          }
        }
      }
    } catch (err) {
      log.error({ err, branchId }, 'whatsapp deferred processing failed');
    }
  });

  return new NextResponse('OK', { status: 200 });
}

export const GET = wrapRouteHandler((req: Request, ctx: unknown) => handleWhatsAppGet(req as NextRequest, ctx as RouteContext));
export const POST = wrapRouteHandler((req: Request, ctx: unknown) => handleWhatsAppPost(req as NextRequest, ctx as RouteContext));
