/**
 * sip.service.ts — CRUD for SIP-trunk phone lines (provider='sip').
 * Moved out of `services/telephone/sip.service.ts` into this folder
 * for SIP add-on isolation. See engine/voice-core.ts for the full file map.
 */
import { db } from '@server/db/drizzle';
import { sql, type SQL } from 'drizzle-orm';
import { initDatabase } from '@server/db/init';
import { NotFoundError, ValidationError } from '@server/errors';
import { bindValue } from '@server/db/bind';

function stripSipPassword(line: SipLine): SipLine {
  const config = typeof line.sip_config === 'string' ? JSON.parse(line.sip_config) : { ...line.sip_config };
  const { sip_password, ...safeConfig } = config;
  return { ...line, sip_config: safeConfig };
}

async function validateAgentOwnership(restaurantId: string, agentId: string | null | undefined): Promise<void> {
  if (!agentId) return;
  const { rows } = await db.execute(sql`SELECT id FROM ai_agents WHERE id = ${agentId} AND restaurant_id = ${restaurantId}`);
  if (!rows[0]) throw new ValidationError('Agent not found or does not belong to this restaurant');
}

export interface SipLine {
  id: string;
  restaurant_id: string;
  branch_id: string | null;
  number: string;
  display_name: string | null;
  type: string;
  provider: string;
  sip_provider_label: string | null;
  sip_config: {
    sip_server?: string;
    sip_username?: string;
    sip_password?: string;
    outbound_proxy?: string;
    registration_status?: 'registered' | 'unregistered' | 'unknown';
    last_checked_at?: string;
    jambonz_carrier_sid?: string;
    jambonz_application_sid?: string;
  };
  assigned_agent_id: string | null;
  assigned_agent_name?: string;
  is_active: boolean;
  call_recording: boolean;
  call_transcription: boolean;
  greeting_override: string | null;
  created_at: string;
  updated_at: string;
}

const SIP_SELECT = sql.raw(`
  p.*,
  a.name AS assigned_agent_name
FROM phone_numbers p
LEFT JOIN ai_agents a ON a.id = p.assigned_agent_id
`);

export async function listSipLines(restaurantId: string): Promise<SipLine[]> {
  await initDatabase();
  const { rows } = await db.execute(sql`SELECT ${SIP_SELECT} WHERE p.restaurant_id = ${restaurantId} AND p.provider = 'sip' ORDER BY p.created_at DESC`);
  return (rows as unknown as SipLine[]).map(stripSipPassword);
}

export async function getSipLine(id: string, restaurantId: string): Promise<SipLine> {
  await initDatabase();
  const { rows } = await db.execute(sql`SELECT ${SIP_SELECT} WHERE p.id = ${id} AND p.restaurant_id = ${restaurantId} AND p.provider = 'sip'`);
  if (!rows[0]) throw new NotFoundError('SIP line');
  return stripSipPassword(rows[0] as unknown as SipLine);
}

export async function createSipLine(restaurantId: string, data: {
  display_name: string;
  sip_server: string;
  sip_username: string;
  sip_password: string;
  outbound_proxy?: string;
  sip_provider_label?: string | null;
  sip_port?: number;
  sip_transport?: 'UDP' | 'TCP' | 'TLS';
  assigned_agent_id?: string | null;
  branch_id?: string | null;
}): Promise<SipLine> {
  await initDatabase();
  if (!data.display_name?.trim()) throw new ValidationError('Display name is required');
  if (!data.sip_server?.trim()) throw new ValidationError('SIP server is required');
  if (!data.sip_username?.trim()) throw new ValidationError('SIP username is required');

  await validateAgentOwnership(restaurantId, data.assigned_agent_id);

  const sipPort = data.sip_port ?? (data.sip_transport === 'TLS' ? 5061 : 5060);
  const sipTransport = data.sip_transport || 'UDP';
  const sipConfig: Record<string, unknown> = {
    sip_server: data.sip_server.trim(),
    sip_username: data.sip_username.trim(),
    sip_password: data.sip_password || '',
    outbound_proxy: data.outbound_proxy?.trim() || '',
    sip_port: sipPort,
    sip_transport: sipTransport,
    registration_status: 'unknown' as const,
  };

  // Hard requirement: provision the jambonz carrier + application first, and
  // bind them so inbound calls hit our /api/webhooks/sip/voice. If anything
  // fails the DB row is never inserted, and any partially-created jambonz
  // resources are torn back down so we never leak orphans.
  const {
    getJambonzCreds, createCarrier, deleteCarrier,
    ensureApplication, deleteApplication, linkCarrierToApplication,
  } = await import('@server/services/telephone/jambonz/jambonz.service');
  const creds = await getJambonzCreds(restaurantId);
  if (!creds) throw new ValidationError('jambonz is not configured for this restaurant');

  const carrier = await createCarrier(creds, {
    name: `${data.display_name.trim()} (${restaurantId.slice(0, 8)})`,
    sip_realm: data.sip_server.trim(),
    username: data.sip_username.trim(),
    password: data.sip_password || undefined,
    outbound_proxy: data.outbound_proxy?.trim() || undefined,
    port: sipPort,
    transport: sipTransport,
    is_active: true,
  });
  if (!carrier) throw new ValidationError('Failed to provision SIP carrier on jambonz');
  sipConfig.jambonz_carrier_sid = carrier.sid;

  // Provision the jambonz Application that binds the call hooks to our webhooks.
  const baseUrl = getWebhookBaseUrl();
  const app = await ensureApplication(creds, {
    name: `${data.display_name.trim()} (${restaurantId.slice(0, 8)})`,
    call_hook: `${baseUrl}/api/webhooks/sip/voice`,
    call_status_hook: `${baseUrl}/api/webhooks/sip/status`,
  });
  if (!app) {
    try { await deleteCarrier(creds, carrier.sid); } catch { /* best-effort */ }
    throw new ValidationError('Failed to provision jambonz application');
  }
  sipConfig.jambonz_application_sid = app.sid;

  // Link the carrier so inbound calls hit the application.
  const linked = await linkCarrierToApplication(creds, carrier.sid, app.sid);
  if (!linked) {
    try { await deleteApplication(creds, app.sid); } catch { /* best-effort */ }
    try { await deleteCarrier(creds, carrier.sid); } catch { /* best-effort */ }
    throw new ValidationError('Failed to bind SIP carrier to jambonz application');
  }

  let createdId: string;
  try {
    const { rows } = await db.execute(sql`
      INSERT INTO phone_numbers (restaurant_id, branch_id, number, display_name, type, provider, sip_provider_label, sip_config, assigned_agent_id, is_active)
      VALUES (${restaurantId}, ${data.branch_id || null}, ${`sip:${data.sip_username}@${data.sip_server}`},
              ${data.display_name.trim()}, 'sip', 'sip', ${data.sip_provider_label || null}, ${JSON.stringify(sipConfig)}::jsonb, ${data.assigned_agent_id || null}, true)
      RETURNING *
    `);
    createdId = (rows[0] as unknown as { id: string }).id;
  } catch (dbErr) {
    // Roll back BOTH jambonz resources so we don't leave orphans.
    try { await deleteApplication(creds, app.sid); } catch { /* best-effort */ }
    try { await deleteCarrier(creds, carrier.sid); } catch { /* best-effort */ }
    throw dbErr;
  }
  return getSipLine(createdId, restaurantId);
}

/**
 * Resolve the public base URL for jambonz webhooks. Prefers an explicit
 * configuration; falls back to the Replit dev domain so previews work
 * out of the box.
 */
function getWebhookBaseUrl(): string {
  const env = process.env.WEBHOOK_BASE_URL
    || process.env.NEXT_PUBLIC_APP_URL
    || (process.env.REPLIT_DEV_DOMAIN ? `https://${process.env.REPLIT_DEV_DOMAIN}` : '');
  if (!env) throw new ValidationError('WEBHOOK_BASE_URL is not configured (required for jambonz application hooks)');
  return env.replace(/\/+$/, '');
}

export async function updateSipLine(id: string, restaurantId: string, data: Record<string, unknown>): Promise<SipLine> {
  await initDatabase();
  const existing = await getSipLine(id, restaurantId);

  if (data.assigned_agent_id !== undefined) {
    await validateAgentOwnership(restaurantId, data.assigned_agent_id as string | null);
  }

  const sets: SQL[] = [];

  if (data.display_name !== undefined) sets.push(sql`display_name = ${bindValue((data.display_name as string)?.trim() || existing.display_name)}`);
  if (data.assigned_agent_id !== undefined) sets.push(sql`assigned_agent_id = ${bindValue((data.assigned_agent_id as string) || null)}`);
  if (data.is_active !== undefined) sets.push(sql`is_active = ${bindValue(data.is_active)}`);
  if (data.call_recording !== undefined) sets.push(sql`call_recording = ${bindValue(data.call_recording)}`);
  if (data.call_transcription !== undefined) sets.push(sql`call_transcription = ${bindValue(data.call_transcription)}`);
  if (data.greeting_override !== undefined) sets.push(sql`greeting_override = ${bindValue((data.greeting_override as string)?.trim() || null)}`);
  if (data.sip_provider_label !== undefined) sets.push(sql`sip_provider_label = ${bindValue((data.sip_provider_label as string)?.trim() || null)}`);

  const sipUpdates: Record<string, unknown> = {};
  if (data.sip_server !== undefined) sipUpdates.sip_server = (data.sip_server as string)?.trim();
  if (data.sip_username !== undefined) sipUpdates.sip_username = (data.sip_username as string)?.trim();
  if (data.sip_password !== undefined) sipUpdates.sip_password = data.sip_password;
  if (data.outbound_proxy !== undefined) sipUpdates.outbound_proxy = (data.outbound_proxy as string)?.trim() || '';
  if (data.sip_port !== undefined) sipUpdates.sip_port = data.sip_port;
  if (data.sip_transport !== undefined) sipUpdates.sip_transport = data.sip_transport;

  if (Object.keys(sipUpdates).length > 0) {
    const currentConfig = typeof existing.sip_config === 'string'
      ? JSON.parse(existing.sip_config)
      : existing.sip_config || {};
    const merged = { ...currentConfig, ...sipUpdates };
    sets.push(sql`sip_config = ${bindValue(JSON.stringify(merged))}::jsonb`);

    if (sipUpdates.sip_server || sipUpdates.sip_username) {
      const server = (sipUpdates.sip_server as string) || currentConfig.sip_server || '';
      const username = (sipUpdates.sip_username as string) || currentConfig.sip_username || '';
      sets.push(sql`number = ${bindValue(`sip:${username}@${server}`)}`);
    }

    // Hard sync: jambonz carrier MUST update successfully or the whole
    // line update is rejected (prevents the DB row drifting from jambonz).
    const carrierSid = currentConfig.jambonz_carrier_sid as string | undefined;
    if (carrierSid) {
      const { getJambonzCreds, updateCarrier } = await import('@server/services/telephone/jambonz/jambonz.service');
      const creds = await getJambonzCreds(restaurantId);
      if (!creds) throw new ValidationError('jambonz is not configured for this restaurant');
      const ok = await updateCarrier(creds, carrierSid, {
        sip_realm: merged.sip_server,
        username: merged.sip_username,
        password: merged.sip_password,
        outbound_proxy: merged.outbound_proxy,
        port: merged.sip_port,
        transport: merged.sip_transport,
      });
      if (!ok) throw new ValidationError('Failed to update SIP carrier on jambonz');
    }
  }

  if (sets.length === 0) throw new ValidationError('No valid fields to update');
  sets.push(sql`updated_at = NOW()`);

  await db.execute(sql`UPDATE phone_numbers SET ${sql.join(sets, sql`, `)} WHERE id = ${id} AND restaurant_id = ${restaurantId}`);

  return getSipLine(id, restaurantId);
}

export async function deleteSipLine(id: string, restaurantId: string): Promise<void> {
  await initDatabase();
  // Hard requirement: tear down the jambonz application + carrier first;
  // only on success do we delete the DB row. This guarantees no orphans.
  const existing = await getSipLine(id, restaurantId);
  const cfg = existing.sip_config as Record<string, unknown>;
  const carrierSid = cfg.jambonz_carrier_sid as string | undefined;
  const applicationSid = cfg.jambonz_application_sid as string | undefined;

  if (carrierSid || applicationSid) {
    const {
      getJambonzCreds, deleteCarrier, deleteApplication, linkCarrierToApplication,
    } = await import('@server/services/telephone/jambonz/jambonz.service');
    const creds = await getJambonzCreds(restaurantId);
    if (!creds) throw new ValidationError('jambonz is not configured for this restaurant');

    // Unlink first so deleting the application doesn't trip a FK guard.
    if (carrierSid && applicationSid) {
      try { await linkCarrierToApplication(creds, carrierSid, null); } catch { /* best-effort */ }
    }
    if (applicationSid) {
      const ok = await deleteApplication(creds, applicationSid);
      if (!ok) throw new ValidationError('Failed to delete jambonz application');
    }
    if (carrierSid) {
      const ok = await deleteCarrier(creds, carrierSid);
      if (!ok) throw new ValidationError('Failed to delete SIP carrier on jambonz');
    }
  }

  const result = await db.execute(sql`DELETE FROM phone_numbers WHERE id = ${id} AND restaurant_id = ${restaurantId} AND provider = 'sip'`);
  if ((result.rowCount ?? 0) === 0) throw new NotFoundError('SIP line');
}

/**
 * Pull live registration state from jambonz and persist it onto sip_config.
 * Used by `POST /api/telephone/sip/:id/refresh` and any UI poll.
 */
export async function refreshSipLineStatus(
  id: string,
  restaurantId: string
): Promise<SipLine> {
  await initDatabase();
  const existing = await getSipLine(id, restaurantId);
  const cfg = existing.sip_config as Record<string, unknown>;
  const carrierSid = cfg.jambonz_carrier_sid as string | undefined;
  const checkedAt = new Date().toISOString();

  let status: 'registered' | 'unregistered' | 'unknown' = 'unknown';
  if (carrierSid) {
    const { getJambonzCreds, getCarrier } = await import('@server/services/telephone/jambonz/jambonz.service');
    const creds = await getJambonzCreds(restaurantId);
    if (creds) {
      const live = await getCarrier(creds, carrierSid);
      if (live?.status === 'registered') status = 'registered';
      else if (live?.status === 'unregistered') status = 'unregistered';
    }
  }

  const merged = { ...cfg, registration_status: status, last_checked_at: checkedAt };
  await db.execute(sql`
    UPDATE phone_numbers
    SET sip_config = ${JSON.stringify(merged)}::jsonb, updated_at = NOW()
    WHERE id = ${id} AND restaurant_id = ${restaurantId} AND provider = 'sip'
  `);
  return getSipLine(id, restaurantId);
}
