import type {JobClaim} from "../queue/claim";
/**
 * O ENVIO do aviso de que a IA está saindo de campo — lado do MOTOR (`pg`).
 *
 * A frase mora em `lib/escalacao/aviso-ao-lead.ts` (uma só, para os dois mundos).
 * Aqui só o encanamento: resolver a disponibilidade real da equipe, montar o
 * corpo e mandá-lo pela MESMA cadeia de guardrails por onde passa tudo que sai
 * daqui — `runBeforeSend`. Nada sai por baixo dela (CLAUDE.md, princípio 2).
 *
 * ## É o irmão de `runDeterministicReentry`
 *
 * `lib/agent-engine/agent/followup-turn.ts` já envia texto escrito em CÓDIGO,
 * sem gastar modelo, atrás da cadeia. Este arquivo é a mesma figura para um
 * momento diferente: lá é a re-entrada, aqui é a saída.
 *
 * ## Duas escolhas de gate que precisam estar escritas
 *
 * 1. **`casesEnabled: false` de propósito.** O `casePromiseGate` existe para o
 *    lead nunca receber promessa-de-humano que o modelo inventou sem abrir caso.
 *    Este corpo promete um humano — e a promessa é VERDADEIRA por construção: a
 *    linha seguinte do chamador executa a passagem. Deixar o gate armado aqui o
 *    faria vetar exatamente a mensagem que ele existe para garantir, e o desfecho
 *    seria o silêncio que este arquivo veio acabar.
 * 2. **`seq: 0`.** O contrato do ledger é "posição da mensagem no turno (1..n)"
 *    (`ChannelSendInput.seq`), e o aviso não é uma mensagem do turno — é a
 *    despedida dele. `0` nunca colide com o `seq` que a tool `send_message`
 *    consome, então o replay pós-crash de um job continua deduplicando por
 *    `(job_id, seq)` sem que um aviso vire `already_sent` de uma fala do modelo
 *    (ou o contrário).
 *
 * ## O que acontece quando a cadeia VETA
 *
 * A passagem para humano acontece do mesmo jeito — quem pediu uma pessoa não
 * pode ficar preso ao automático porque a janela de envio fechou. Mas o desfecho
 * volta ao chamador para virar linha no aviso da Central: o atendente que abre a
 * conversa precisa saber se o cliente foi avisado ou está esperando no escuro.
 * Falhar fechado na AÇÃO, aberto na INFORMAÇÃO.
 */
import type pg from 'pg';

import { expectativaDeAtendimento } from '@/lib/escalacao/disponibilidade';
import { deriveLgpdFromContact } from '../guardrails/lgpd/legal-basis';
import { textoDoAviso, type MotivoDoAviso } from '@/lib/escalacao/aviso-ao-lead';
// Dois `MotivoDoAviso` no repositório, e eles respondem perguntas DIFERENTES: o
// de cima é "que frase o cliente lê"; este é "por que o cliente NÃO leu nada".
// O apelido existe para que ninguém os confunda numa leitura rápida.
import type {
  DesfechoDoAvisoAoCliente,
  MotivoDoAviso as CodigoDeAvisoNaoEntregue,
} from '@/lib/escalacao/passagem';

import type { ChannelAdapter } from '../channel-adapter';
import { runBeforeSend, type TipoDeEnvio } from '../guardrails/before-send';
import type { LgpdInput } from '../guardrails/lgpd/legal-basis';
import type { Logger } from '../obs/logger';

/**
 * `seq` do aviso no ledger de envio. Ver o cabeçalho: o turno usa 1..n.
 */
export const SEQ_DO_AVISO = 0;

/**
 * Os vetos da cadeia que TÊM par no vocabulário fechado da passagem.
 *
 * Parcial de propósito: o que não está aqui vira `cliente_avisado = false` com
 * `aviso_motivo_codigo` nulo, e a tela diz "motivo desconhecido" — que é a
 * verdade disponível. Um mapa exaustivo obrigaria este arquivo a conhecer cada
 * gate novo só para não mentir.
 */
const CODIGO_DO_VETO: Readonly<Record<string, CodigoDeAvisoNaoEntregue>> = {
  messaging_window_closed: 'fora_da_janela',
  pre_go_live: 'pre_go_live',
  pre_go_live_indisponivel: 'pre_go_live',
  channel_archived: 'canal_arquivado',
  missing_phone_number: 'sem_telefone',
};

/**
 * O que aconteceu com o aviso — vai ao log e ao aviso da Central, nunca ao lead.
 *
 * É o MESMO tipo que o emissor do lado do CRM devolve (`DesfechoDoAvisoDoCrm`),
 * e ele mora em `lib/escalacao/passagem.ts` de propósito: enquanto eram dois,
 * um deles era um `{ avisado: boolean }` solto, e foi por essa folga que
 * "o cliente JÁ FOI avisado" era dito sem ninguém olhar o desfecho do envio.
 */
export type DesfechoDoAviso = DesfechoDoAvisoAoCliente;

export interface AvisoDeEscalacaoIds {
  jobClaim?:JobClaim;
  tenantId: string;
  leadId: string;
  conversationId: string;
  channelSessionId: string;
  jobId: string;
}

export interface AvisoDeEscalacaoOpts {
  motivo: MotivoDoAviso;
  channel: ChannelAdapter;
  /** `contacts.is_blocked` lido no contexto deste turno (fonte confiável). */
  optedOutThisTurn: boolean;
  now: Date;
  log: Logger;
  lgpd?: LgpdInput;
  agentId?: string | null;
  /**
   * A frase própria do tenant (#3725) — `ai_agents.config.aviso_de_handoff`.
   * `null`/ausente = o padrão do produto (`textoDoAviso`). Quem lê a config é o
   * chamador do turno: esta função não faz consulta a mais para uma frase.
   */
  fraseConfigurada?: string | null;
  disclosureMode?: 'inject' | 'veto';
  sleep?: (ms: number) => Promise<void>;
  /**
   * O TIPO do envio que este aviso É (#2112, coluna `before_send_traces.tipo_envio`).
   *
   * `resposta` (DEFAULT) = o aviso responde a quem escreveu e pediu pessoa, que é
   * o caso do turno de inbound/case — a janela que vale é a de `resposta_*` (0495).
   * `disparo` = a escalação nasceu DENTRO de um follow-up: ali ninguém escreveu
   * nada agora, é retomada, e a janela que vale é a de `window_*`. Com a janela de
   * resposta mais estreita que a de disparo, o `resposta: true` fixo vetava pelo
   * lado errado e o trace registrava um veto que a janela de disparo não daria.
   *
   * Default `resposta` de propósito: todo chamador que hoje não declara origem é o
   * caminho de resposta (#1984), e omitir o campo não pode abrir o disparo.
   */
  origem?: TipoDeEnvio;
}

/**
 * Avisa o lead de que uma pessoa vai assumir. NUNCA lança: um erro aqui não pode
 * impedir a passagem que ele antecede — o cliente sem aviso é ruim, o cliente
 * preso a um automático que já decidiu sair é pior.
 */
export async function avisarLeadDaEscalacao(
  pool: pg.Pool,
  ids: AvisoDeEscalacaoIds,
  opts: AvisoDeEscalacaoOpts,
): Promise<DesfechoDoAviso> {
  let body: string;
  // O aviso sai no idioma da ORGANIZAÇÃO (ver `textoDoAviso`). Leitura que
  // falha não impede o aviso: cai no português, como antes.
  let idioma: string | null = null;
  try {
    const { rows } = await pool.query<{ locale: string | null }>(
      'select locale from organizations where id = $1',
      [ids.tenantId],
    );
    idioma = rows[0]?.locale ?? null;
  } catch {
    idioma = null;
  }
  try {
    const { quem } = await expectativaDeAtendimento(pool, ids.tenantId, opts.now);
    body = textoDoAviso(opts.motivo, quem, ids.leadId, idioma, opts.fraseConfigurada ?? null);
  } catch (err) {
    // `expectativaDeAtendimento` já tem rede própria; se ainda assim quebrar,
    // a frase conservadora (sem prazo) é a certa — nunca a ausência de frase.
    opts.log.warn('aviso de escalação: disponibilidade não lida, usando a frase conservadora', {
      error: err instanceof Error ? err.message.slice(0, 120) : 'erro desconhecido',
    });
    body = textoDoAviso(opts.motivo, null, ids.leadId, idioma, opts.fraseConfigurada ?? null);
  }

  try {
    const chain = await runBeforeSend({
      pool,
      log: opts.log,
      tenantId: ids.tenantId,
      leadId: ids.leadId,
      jobId: ids.jobId,
      channelSessionId: ids.channelSessionId,
      body,
      optedOutThisTurn: opts.optedOutThisTurn,
      // O TIPO do envio decide qual janela o gate avalia (#2112): `resposta_*`
      // (0495) quando o aviso responde a quem escreveu e pediu pessoa, e
      // `window_*` quando ele nasce dentro de um follow-up (`origem: 'disparo'`)
      // — retomada, não resposta. O `true` fixo de #1984 estava certo para o
      // turno de inbound e ERRADO para o follow-up: com a janela de resposta mais
      // estreita que a de disparo, o aviso de quem pediu pessoa dentro de um
      // disparo era vetado pela janela de quem não escreveu nada.
      resposta: opts.origem !== 'disparo',
      // Ver `GateContext.spinningEnforced`: com o gate armado, a terceira pessoa
      // a ser escalada na mesma janela do número receberia silêncio — pelo
      // guardrail. Este é o ÚNICO chamador que o desarma.
      enforceSpinning: false,
      // Texto fixo deste arquivo, não da IA: a restrição comercial (#2877) não o
      // julga. Sem o `false`, a cadeia leria a escolha da organização — e o aviso
      // que a própria passagem comercial manda poderia ser barrado por ela.
      enforceCommercialRestriction: false,
      // Mesmo débito declarado no caminho do agente e no da re-entrada: o
      // daily_message_limit do CRM ainda não é lido no runtime.
      crmDailyLimit: null,
      now: opts.now,
      ...(opts.sleep !== undefined ? { sleep: opts.sleep } : {}),
      ...(opts.lgpd !== undefined ? { lgpd: opts.lgpd } : {}),
      ...(opts.agentId !== undefined ? { agentId: opts.agentId } : {}),
      ...(opts.disclosureMode !== undefined ? { disclosureMode: opts.disclosureMode } : {}),
      send: (finalBody) =>
        opts.channel.send({
          tenantId: ids.tenantId,
          leadId: ids.leadId,
          jobId: ids.jobId,
          jobClaim:ids.jobClaim,
          seq: SEQ_DO_AVISO,
          conversationId: ids.conversationId,
          body: finalBody,
        }),
    });

    if (chain.status === 'vetoed') {
      opts.log.info('aviso de escalação vetado pela cadeia — a passagem acontece assim mesmo', {
        code: chain.code,
      });
      const codigo = CODIGO_DO_VETO[chain.code];
      return {
        avisado: false,
        // O código REAL do gate: `contato_bloqueado` (opt-out) e
        // `atendimento_com_humano` (a conversa foi passada para a equipe) são
        // desfechos diferentes, e este campo é lido por quem diagnostica o
        // aviso que não saiu (#3310).
        porque: chain.code,
        ...(codigo !== undefined ? { motivoCodigo: codigo } : {}),
      };
    }

    const outcome = chain.outcome;
    switch (outcome.kind) {
      case 'sent':
      case 'already_sent':
        opts.log.info('lead avisado antes da passagem para humano', { kind: outcome.kind });
        return { avisado: true };
      case 'queued':
        // ⚠️ ESTE RAMO MUDOU, e a versão anterior está escrita aqui de propósito:
        // ela devolvia `{ avisado: true }` com o argumento de que `queued` é
        // "custódia do CRM". O argumento é bom para quem opera a fila e ERRADO
        // para quem vai atender: `queued` é o desfecho de sessão fora do ar e de
        // instalação sem transporte configurado, ou seja, o cliente NÃO recebeu
        // nada e pode nunca receber. Quem abre a conversa precisa saber que a
        // pessoa do outro lado está esperando sem ter sido avisada — é o que
        // muda a primeira frase que ele digita. A fila continua tendo dono e
        // watchdog; o que ela não pode é virar uma afirmação de entrega.
        opts.log.info('aviso ao lead ficou na fila — o cliente ainda não recebeu', {
          kind: outcome.kind,
        });
        return { avisado: false, porque: 'na_fila_canal_fora', motivoCodigo: 'na_fila_canal_fora' };
      case 'blocked':
        // Sem `motivoCodigo`: o vocabulário fechado da passagem não tem um par
        // para "o contato está bloqueado", e inventar um seria escrever na tela
        // de quem atende uma razão que ninguém mediu.
        return { avisado: false, porque: 'contato_bloqueado' };
      case 'failed':
        return { avisado: false, porque: 'canal_falhou', motivoCodigo: 'falhou_no_envio' };
      case 'unavailable':
        return { avisado: false, porque: 'canal_indisponivel', motivoCodigo: 'falhou_no_envio' };
    }
  } catch (err) {
    opts.log.warn('aviso de escalação falhou — a passagem acontece assim mesmo', {
      error: err instanceof Error ? err.message.slice(0, 200) : 'erro desconhecido',
    });
    return { avisado: false, porque: 'erro_no_envio' };
  }
}


/**
 * A mesma coisa, para quem NÃO tem o contexto do turno na mão.
 *
 * A escolta do orçamento (`comHandoffSeOrcamentoAcabar`) envolve o turno INTEIRO
 * — inclusive a leitura do contexto —, então quando ela precisa avisar, o
 * `getLeadContext` pode nem ter rodado. Em vez de deixar esse caminho sem os
 * insumos dos gates (o de LGPD viraria no-op, e um contato anonimizado receberia
 * mensagem), ele lê a linha do contato direto do banco. É UMA query, e só no
 * caminho de erro — o caminho feliz não paga nada por ela.
 */
export async function avisarLeadLendoOContato(
  pool: pg.Pool,
  ids: AvisoDeEscalacaoIds,
  opts: Omit<AvisoDeEscalacaoOpts, 'optedOutThisTurn' | 'lgpd'>,
): Promise<DesfechoDoAviso> {
  let optedOutThisTurn = false;
  let lgpd: LgpdInput | undefined;
  try {
    const { rows } = await pool.query<{
      is_blocked: boolean;
      is_anonymized: boolean;
      source: string | null;
      consent: Record<string, unknown> | null;
    }>(
      'select is_blocked, is_anonymized, source, consent from contacts where organization_id = $1 and id = $2',
      [ids.tenantId, ids.leadId],
    );
    const c = rows[0];
    if (c !== undefined) {
      optedOutThisTurn = c.is_blocked === true;
      // isProspecting=false: isto responde a alguém que JÁ está numa conversa —
      // mesma leitura de `get-lead-context.ts`.
      lgpd = deriveLgpdFromContact(
        { source: c.source, consent: c.consent, is_anonymized: c.is_anonymized },
        false,
      );
    }
  } catch (err) {
    // Sem a leitura, o `stopGate` ainda lê as travas DIRETO da fonte sob o lock
    // (`readStopFlags`) — o que se perde é só o gate de LGPD, e perder o aviso
    // inteiro por causa disso seria trocar um risco pequeno por um dano certo.
    opts.log.warn('aviso de escalação: contato não lido, seguindo com o que a cadeia lê sozinha', {
      error: err instanceof Error ? err.message.slice(0, 120) : 'erro desconhecido',
    });
  }
  return avisarLeadDaEscalacao(pool, ids, {
    ...opts,
    optedOutThisTurn,
    ...(lgpd !== undefined ? { lgpd } : {}),
  });
}
